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

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


The following commit(s) were added to refs/heads/master by this push:
     new e0bf4874070 [improvement](be) Use thread pool scan scheduler by 
default (#68610)
e0bf4874070 is described below

commit e0bf4874070ba0b6837ad93f695ea63532b77a47
Author: Jerry Hu <[email protected]>
AuthorDate: Mon Oct 19 15:53:55 2026 +0800

    [improvement](be) Use thread pool scan scheduler by default (#68610)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #51690, #58144, #58273, #65814, #67070
    
    Problem Summary: Workload groups pick the scan scheduler for internal
    and
    external table scans from `enable_task_executor_in_internal_table` and
    `enable_task_executor_in_external_table`. Both configs currently default
    to
    `true` on master, so every workload group creates
    `TaskExecutorSimplifiedScanScheduler` (the time-sharing task executor)
    for
    local (`ls_*`) and remote (`rs_*`) scans. The time-sharing executor is
    still
    maturing (for example the self-deadlock fixed in #58273; the
    internal-table
    default was already reverted once in #58144), so the default should be
    the
    `ThreadPoolSimplifiedScanScheduler` for both internal and external
    tables.
    
    This change sets both configs' default values to `false`. The task
    executor
    can still be enabled explicitly through `be.conf`.
    
    Making the thread pool path the default exposed four gaps.
    
    1. Pending scanners after an exhausted shared LIMIT. For a `SELECT ...
    LIMIT n`
    over many tablets, the thread pool admission
    (`ScannerContext::can_admit_scan_task`,
    rewritten in #67070) keeps admitting the remaining pending scanners
    after peer
    scanners have exhausted the shared scan LIMIT. Admission is kept that
    way on
    purpose: holding pending scanners back based on the in-flight or
    completed
    task counters, as #65814 does on the TaskExecutor path, ties the
    schedule to
    bookkeeping that is easy to get wrong and can leave a context with
    nothing to
    wake the operator. Instead, `Scanner::get_block_after_projects()` now
    checks
    the shared LIMIT before it reads: once the counter is exhausted, the
    scanner
    returns an empty block, so an admitted scanner never reads its range for
    rows
    the operator will cut anyway. It does not report EOS for that: EOS means
    the
    underlying read is exhausted, while the LIMIT is applied by the scan
    operator,
    which stops the scanners after `reached_limit()`;
    `ScannerContext::get_block_from_queue()` also finishes the context once
    the
    counter is exhausted with nothing in flight. The two pre-existing checks
    in
    `Scanner::get_block()` that set `eof` from the shared counter are
    removed for
    the same reason, so the shared LIMIT is handled in one place.
    
    2. Rows held in scanner padding under a shared LIMIT.
    `Scanner::get_block()`
    charges the shared LIMIT counter as soon as rows pass the filters, but
    with
    projections `get_block_after_projects()` may keep a small block in
    `_padding_block` and emit a later block first. Once peer scanners
    exhaust the
    counter, the context reaches EOS without running that scanner again, and
    the
    charged rows it holds are lost; for example, a parallel projected
    `LIMIT 4096` could return 4086 rows. The counter is now decremented only
    by
    the rows a call actually returns, so held padding is never charged and
    the
    counter equals "LIMIT minus returned rows" between calls. The padding
    batch
    target is capped by the remaining LIMIT when a call starts, so a scanner
    does
    not read on just to fill half a batch when fewer rows are needed, and
    the
    read loop stops as soon as peers exhaust the LIMIT, returning the rows
    it
    already holds. Projected scans with selective filters keep coalescing
    small
    blocks. The scan does not cap its output at the LIMIT (the operator cuts
    extra rows), so no further accounting is needed.
    
    3. Exceptions in the row-ID fetch RPC callback. `multiget_data_v2`
    submits
    its callback to the remote scan scheduler; on a `ThreadPool` worker
    there is
    no exception boundary, and `RowIdStorageReader::read_by_rowids()`
    constructs
    `SlotDescriptor(pslot)` before its local `try`, so an unknown
    `PTypeNode.type` would throw `doris::Exception` and terminate the BE.
    The
    callback now calls `RowIdStorageReader::read_by_rowids_for_rpc()`, which
    converts a thrown exception into an error status on the response before
    the
    closure completes the RPC. `ExecEnv::set_id_manager()` is added under
    `BE_TEST` for the unit test.
    
    4. The shared LIMIT pointer of a scanner initialized while the counter
    is
    negative. `ScanLocalState::shared_scan_limit_ptr()` returned null
    whenever
    the live counter was negative, and `Scanner::init()` caches that pointer
    once. Concurrent scanners can return more than the LIMIT before the
    operator
    makes its final cut, leaving the counter negative, so a scanner whose
    local
    state opened at that moment never checked or charged the shared LIMIT
    and
    could read a long selective tail after peers had satisfied the query.
    The pointer is now returned whenever the operator has a SQL LIMIT, which
    is
    how `ScannerContext` already decides it.
    
    ### Release note
    
    The BE configs `enable_task_executor_in_internal_table` and
    `enable_task_executor_in_external_table` now default to `false`;
    internal
    and external table scans use the thread pool scan scheduler by default.
    
    ### Check List (For Author)
    
    - Test:
    - Unit Test: added
    `WorkloadGroupManagerTest.DefaultScanSchedulersUseThreadPool`,
          `ScannerContextTest.thread_pool_admission_ignores_shared_limit`,
    
    
`ScannerProjectionTest.shared_limit_exhausted_before_call_returns_empty_block_without_eos`,
          `ScannerProjectionTest.shared_limit_counts_only_emitted_rows`,
    
    
`ScannerProjectionTest.shared_limit_drops_padding_after_peer_satisfies_limit`,
    
    `ScannerProjectionTest.shared_limit_flushes_padding_before_reading_more`,
    `ScannerProjectionTest.shared_limit_flushes_merged_padding_at_limit`,
    
    
`ScannerProjectionTest.shared_limit_counts_returned_rows_without_projection`,
    
    `ScannerProjectionTest.shared_limit_counts_returned_rows_without_padding`,
          `ScannerProjectionTest.shared_limit_keeps_padding_small_blocks`,
    
    
`ScannerProjectionTest.shared_limit_exhausted_by_own_rows_stops_reading_without_eos`,
    
    
`ScannerProjectionTest.shared_limit_exhausted_by_peer_mid_read_returns_held_rows_without_eos`,
    
    
`ScannerProjectionTest.shared_limit_observed_by_scanner_initialized_under_negative_counter`,
          `ScannerProjectionTest.no_shared_limit_without_sql_limit`
    and
    `RowIdStorageReaderTest.RpcHelperReportsThrownExceptionOnThreadPoolPath`
    (runs the RPC helper on a real `ThreadPoolSimplifiedScanScheduler`;
          the test process aborts without the fix);
          ran `ScannerProjectionTest.*`, `ScannerContextTest.*`,
          `ScannerLateArrivalRfTest.*`, `WorkloadGroupManagerTest.*`,
          `RowIdStorageReaderTest.*`, `SubmitExternalScanTasksTest.*`
        - Regression test: ran on a local cluster with the new
          default: query_p0/limit (19 suites) and
          correctness_p0/test_shared_scan_limit_pending_tasks
    - Behavior changed: Yes (default scan scheduler for internal and
    external
    table scans changes from the task executor to the thread pool; it can be
      switched back via the two BE configs)
    - Does this need documentation: No
---
 be/src/common/config.cpp                           |   4 +-
 be/src/exec/operator/mock_scan_operator.h          |   6 +
 be/src/exec/operator/scan_operator.cpp             |  10 +-
 be/src/exec/rowid_fetcher.cpp                      |   7 +
 be/src/exec/rowid_fetcher.h                        |   8 +
 be/src/exec/scan/scanner.cpp                       | 106 +++--
 be/src/exec/scan/scanner.h                         |   9 +
 be/src/exec/scan/scanner_context.cpp               |   9 +-
 be/src/runtime/exec_env.h                          |   1 +
 be/src/service/internal_service.cpp                |   4 +-
 be/test/exec/rowid_fetcher_test.cpp                |  72 +++
 be/test/exec/scan/scanner_context_test.cpp         | 134 ++++++
 be/test/exec/scan/scanner_late_arrival_rf_test.cpp | 485 +++++++++++++++++++++
 .../workload_group/workload_group_manager_test.cpp |  57 +++
 14 files changed, 856 insertions(+), 56 deletions(-)

diff --git a/be/src/common/config.cpp b/be/src/common/config.cpp
index 1361137bae7..cffac653cf1 100644
--- a/be/src/common/config.cpp
+++ b/be/src/common/config.cpp
@@ -339,9 +339,9 @@ DEFINE_Int32(task_executor_max_concurrency_per_task, "-1");
 DEFINE_Int32(task_executor_initial_max_concurrency_per_task, "-1");
 
 // Enable task executor in internal table scan.
-DEFINE_Bool(enable_task_executor_in_internal_table, "true");
+DEFINE_Bool(enable_task_executor_in_internal_table, "false");
 // Enable task executor in external table scan.
-DEFINE_Bool(enable_task_executor_in_external_table, "true");
+DEFINE_Bool(enable_task_executor_in_external_table, "false");
 
 // number of scanner thread pool size for olap table
 // and the min thread num of remote scanner thread pool
diff --git a/be/src/exec/operator/mock_scan_operator.h 
b/be/src/exec/operator/mock_scan_operator.h
index 609e631ce8c..27f1f8ef53d 100644
--- a/be/src/exec/operator/mock_scan_operator.h
+++ b/be/src/exec/operator/mock_scan_operator.h
@@ -84,6 +84,12 @@ public:
         _output_blocks.push_back(std::move(block));
     }
 
+    // Mirrors the constructor of ScanOperatorX: the shared counter starts at 
the SQL LIMIT.
+    void set_limit_for_test(int64_t limit) {
+        _limit = limit;
+        _shared_scan_limit.store(limit, std::memory_order_relaxed);
+    }
+
     Status get_block_impl(RuntimeState* state, Block* block, bool* eos) 
override {
         if (_output_blocks.empty()) {
             *eos = true;
diff --git a/be/src/exec/operator/scan_operator.cpp 
b/be/src/exec/operator/scan_operator.cpp
index 7a0c4042883..e01fdc33bf0 100644
--- a/be/src/exec/operator/scan_operator.cpp
+++ b/be/src/exec/operator/scan_operator.cpp
@@ -1072,10 +1072,12 @@ int64_t ScanLocalState<Derived>::limit_per_scanner() {
 
 template <typename Derived>
 std::atomic<int64_t>* ScanLocalState<Derived>::shared_scan_limit_ptr() {
-    auto* p = &_parent->cast<typename Derived::Parent>()._shared_scan_limit;
-    // -1 means "no SQL LIMIT" — return nullptr so callers naturally skip
-    // all limit logic.
-    return p->load(std::memory_order_relaxed) < 0 ? nullptr : p;
+    auto& parent = _parent->cast<typename Derived::Parent>();
+    // Decide from the SQL LIMIT, not from the counter. -1 means "no SQL 
LIMIT" — return nullptr
+    // so callers naturally skip all limit logic. Concurrent scanners can 
return more than the
+    // LIMIT before the operator makes its final cut, leaving this counter 
negative. A scanner
+    // initialized then must still observe that the shared LIMIT is exhausted.
+    return parent.limit() < 0 ? nullptr : &parent._shared_scan_limit;
 }
 
 template <typename Derived>
diff --git a/be/src/exec/rowid_fetcher.cpp b/be/src/exec/rowid_fetcher.cpp
index 3595a735b67..3f9c0efc82e 100644
--- a/be/src/exec/rowid_fetcher.cpp
+++ b/be/src/exec/rowid_fetcher.cpp
@@ -196,6 +196,13 @@ static void scatter_scan_blocks_to_result_block(
     }
 }
 
+void RowIdStorageReader::read_by_rowids_for_rpc(const PMultiGetRequestV2& 
request,
+                                                PMultiGetResponseV2* response) 
{
+    Status st = Status::OK();
+    ASSIGN_STATUS_IF_CATCH_EXCEPTION(st = read_by_rowids(request, response), 
st);
+    st.to_protobuf(response->mutable_status());
+}
+
 Status RowIdStorageReader::read_by_rowids(const PMultiGetRequestV2& request,
                                           PMultiGetResponseV2* response) {
     if (request.request_block_descs_size()) {
diff --git a/be/src/exec/rowid_fetcher.h b/be/src/exec/rowid_fetcher.h
index 60b18fb8e82..916b77ff214 100644
--- a/be/src/exec/rowid_fetcher.h
+++ b/be/src/exec/rowid_fetcher.h
@@ -82,6 +82,14 @@ public:
 
     static Status read_by_rowids(const PMultiGetRequestV2& request, 
PMultiGetResponseV2* response);
 
+    // Runs read_by_rowids() for the multiget_data_v2 RPC and serializes the 
outcome into the
+    // response status. The remote scan scheduler runs the RPC callback on a 
plain ThreadPool
+    // worker, which has no exception boundary of its own, so a 
doris::Exception thrown while
+    // reading (for example by a slot of the request whose PTypeNode this BE 
does not know) is
+    // converted into an error status here instead of terminating the process.
+    static void read_by_rowids_for_rpc(const PMultiGetRequestV2& request,
+                                       PMultiGetResponseV2* response);
+
 private:
     static bool should_use_file_scanner_v2(const TQueryOptions& query_options,
                                            const TFileScanRangeParams& 
scan_params,
diff --git a/be/src/exec/scan/scanner.cpp b/be/src/exec/scan/scanner.cpp
index 836dacf7125..c4eec1e8f67 100644
--- a/be/src/exec/scan/scanner.cpp
+++ b/be/src/exec/scan/scanner.cpp
@@ -19,6 +19,8 @@
 
 #include <glog/logging.h>
 
+#include <algorithm>
+
 #include "common/config.h"
 #include "common/status.h"
 #include "core/block/column_with_type_and_name.h"
@@ -87,8 +89,18 @@ Status Scanner::init(RuntimeState* state, const 
VExprContextSPtrs& conjuncts) {
     return Status::OK();
 }
 
+// Keep the single-use padding loop inline with its projection and LIMIT 
accounting.
+// NOLINTNEXTLINE(readability-function-cognitive-complexity)
 Status Scanner::get_block_after_projects(RuntimeState* state, Block* block, 
bool* eos) {
     
SCOPED_CONCURRENCY_COUNT(ConcurrencyStatsManager::instance().vscanner_get_block);
+    // Peer scanners may have exhausted the shared LIMIT while this scanner 
waited to be
+    // scheduled. Admission does not look at the LIMIT, so the scanner returns 
an empty block
+    // instead of reading on. It does not report EOS: the operator already 
holds enough rows and
+    // stops the scanners after cutting the LIMIT itself, and ScannerContext 
parks the scanner
+    // rather than resubmitting it while the outstanding work drains.
+    if (_shared_scan_limit_exhausted()) {
+        return Status::OK();
+    }
     const auto& row_descriptor = 
_local_state->_parent->operator_row_desc_before_projection();
     if (_has_projection) {
         _origin_block.clear_column_data(row_descriptor.num_slots());
@@ -99,58 +111,68 @@ Status Scanner::get_block_after_projects(RuntimeState* 
state, Block* block, bool
             // before the next split is read. Padding those blocks first would 
make correctness
             // depend on whether two file tails happen to share one output 
batch.
             RETURN_IF_ERROR(get_block(state, &_origin_block, eos));
-            return _do_projections(&_origin_block, block);
-        }
-        const auto min_batch_size = std::max(state->batch_size() / 2, 1);
-        const auto block_max_bytes = state->preferred_block_size_bytes();
-        while (_padding_block.rows() < min_batch_size && 
_padding_block.bytes() < block_max_bytes &&
-               !*eos) {
-            RETURN_IF_ERROR(get_block(state, &_origin_block, eos));
-            if (*eos) {
-                // For the final block, merge any padding directly and return 
eos in this call.
-                // The merged tail can be larger than the target batch, but 
each source block is
-                // already bounded by the lower scanner.
-                RETURN_IF_ERROR(_merge_padding_block());
-                _origin_block.clear_column_data(row_descriptor.num_slots());
-                break;
-            }
-            if (_origin_block.rows() >= min_batch_size) {
-                break;
+        } else {
+            auto min_batch_size = std::max(state->batch_size() / 2, 1);
+            if (_shared_scan_limit) {
+                // Pending padding is not charged until it is returned. Bound 
this call's batch
+                // target by the shared remaining rows to avoid reading on 
just to fill a batch.
+                min_batch_size = cast_set<int>(std::clamp<int64_t>(
+                        _shared_scan_limit->load(std::memory_order_acquire), 
0, min_batch_size));
             }
+            const auto block_max_bytes = state->preferred_block_size_bytes();
+            // Stop reading as soon as peers exhaust the shared LIMIT; the 
rows already held are
+            // still returned below.
+            while (_padding_block.rows() < min_batch_size &&
+                   _padding_block.bytes() < block_max_bytes && !*eos &&
+                   !_shared_scan_limit_exhausted()) {
+                RETURN_IF_ERROR(get_block(state, &_origin_block, eos));
+                if (*eos) {
+                    // For the final block, merge any padding directly and 
return eos in this call.
+                    // The merged tail can be larger than the target batch, 
but each source block
+                    // is already bounded by the lower scanner.
+                    RETURN_IF_ERROR(_merge_padding_block());
+                    
_origin_block.clear_column_data(row_descriptor.num_slots());
+                    break;
+                }
+                if (_origin_block.rows() >= min_batch_size) {
+                    break;
+                }
 
-            if (_origin_block.rows() + _padding_block.rows() <= 
state->batch_size() &&
-                _origin_block.bytes() + _padding_block.bytes() <= 
block_max_bytes) {
-                RETURN_IF_ERROR(_merge_padding_block());
-                _origin_block.clear_column_data(row_descriptor.num_slots());
-            } else {
-                if (_origin_block.rows() < _padding_block.rows()) {
-                    _padding_block.swap(_origin_block);
+                if (_origin_block.rows() + _padding_block.rows() <= 
state->batch_size() &&
+                    _origin_block.bytes() + _padding_block.bytes() <= 
block_max_bytes) {
+                    RETURN_IF_ERROR(_merge_padding_block());
+                    
_origin_block.clear_column_data(row_descriptor.num_slots());
+                } else {
+                    if (_origin_block.rows() < _padding_block.rows()) {
+                        _padding_block.swap(_origin_block);
+                    }
+                    break;
                 }
-                break;
             }
-        }
 
-        if (_origin_block.empty() && !_padding_block.empty()) {
-            _padding_block.swap(_origin_block);
+            if (_origin_block.empty() && !_padding_block.empty()) {
+                _padding_block.swap(_origin_block);
+            }
         }
-        return _do_projections(&_origin_block, block);
+        RETURN_IF_ERROR(_do_projections(&_origin_block, block));
     } else {
-        return get_block(state, block, eos);
+        RETURN_IF_ERROR(get_block(state, block, eos));
+    }
+
+    // Only rows actually produced by this call consume the shared LIMIT. In 
particular, padding
+    // kept for a later call must not make the context finish before that call 
can return it.
+    // Exhausting the counter is not EOS either: the next call returns an 
empty block, and the
+    // scan operator stops this scanner once it has cut the LIMIT.
+    if (_shared_scan_limit != nullptr && block->rows() > 0) {
+        _shared_scan_limit->fetch_sub(cast_set<int64_t>(block->rows()), 
std::memory_order_acq_rel);
     }
+    return Status::OK();
 }
 
 Status Scanner::get_block(RuntimeState* state, Block* block, bool* eof) {
     // only empty block should be here
     DCHECK(block->rows() == 0);
 
-    // Stop early if other scanners have already collected enough rows
-    // for the SQL LIMIT. Skipped when _shared_scan_limit is null (topn
-    // path or no LIMIT).
-    if (_shared_scan_limit && 
_shared_scan_limit->load(std::memory_order_acquire) <= 0) {
-        *eof = true;
-        return Status::OK();
-    }
-
     // scanner running time
     SCOPED_RAW_TIMER(&_per_scanner_timer);
     int64_t rows_read_threshold = _num_rows_read + 
config::doris_scanner_row_num;
@@ -183,13 +205,6 @@ Status Scanner::get_block(RuntimeState* state, Block* 
block, bool* eof) {
             }
             // record rows return (after filter) for _limit check
             _num_rows_return += block->rows();
-            // Publish progress to the shared counter so peer scanners can
-            // observe it. The counter may go negative when several scanners
-            // subtract concurrently; that is harmless because the operator's
-            // reached_limit() makes the final cut.
-            if (_shared_scan_limit && block->rows() > 0) {
-                _shared_scan_limit->fetch_sub(block->rows(), 
std::memory_order_acq_rel);
-            }
         } while (!_should_stop && !state->is_cancelled() && block->rows() == 0 
&& !(*eof) &&
                  _num_rows_read < rows_read_threshold);
     }
@@ -202,7 +217,6 @@ Status Scanner::get_block(RuntimeState* state, Block* 
block, bool* eof) {
     // set eof to true if per scanner limit is reached
     // currently for query: ORDER BY key LIMIT n
     *eof = *eof || (_limit > 0 && _num_rows_return >= _limit);
-    *eof = *eof || (_shared_scan_limit && 
_shared_scan_limit->load(std::memory_order_acquire) <= 0);
 
     return Status::OK();
 }
diff --git a/be/src/exec/scan/scanner.h b/be/src/exec/scan/scanner.h
index 927472b2fa2..dfed572406e 100644
--- a/be/src/exec/scan/scanner.h
+++ b/be/src/exec/scan/scanner.h
@@ -140,6 +140,15 @@ protected:
         return Status::OK();
     }
 
+    // True once scanners of this operator have produced the whole shared 
LIMIT. The scanner then
+    // stops reading but does not report EOS for it: EOS means the underlying 
read is exhausted,
+    // while the LIMIT is applied by the scan operator, which stops the 
scanners once it holds
+    // enough rows.
+    bool _shared_scan_limit_exhausted() const {
+        return _shared_scan_limit != nullptr &&
+               _shared_scan_limit->load(std::memory_order_acquire) <= 0;
+    }
+
     // Update the counters before closing this scanner
     virtual void _collect_profile_before_close();
 
diff --git a/be/src/exec/scan/scanner_context.cpp 
b/be/src/exec/scan/scanner_context.cpp
index c8727893f49..4c4bb8ae939 100644
--- a/be/src/exec/scan/scanner_context.cpp
+++ b/be/src/exec/scan/scanner_context.cpp
@@ -418,6 +418,12 @@ Status ScannerContext::get_block_from_queue(RuntimeState* 
state, Block* block, b
          (_is_shared_scan_limit_exhausted() && _in_flight_tasks_num == 0))) {
         _set_scanner_done();
         _is_finished = true;
+    } else if (scan_task != nullptr && !scan_task->is_eos() && 
_is_shared_scan_limit_exhausted()) {
+        // Scanners of this operator have already produced the whole shared 
LIMIT, so this scanner
+        // would only return empty blocks until the outstanding work drains. 
Park it instead of
+        // resubmitting it. The finish check above did not fire, so a 
completed or in-flight task
+        // remains to wake the operator, and the context finishes once nothing 
is in flight.
+        push_pending_scan_task(std::move(scan_task), l);
     } else if (scan_task != nullptr) {
         // Check the terminal state before scheduling more work. In 
particular, after the shared
         // LIMIT is exhausted, submitting another Context runnable can turn a 
completed query into
@@ -867,7 +873,8 @@ std::shared_ptr<ScanTask> 
ScannerContext::_pull_next_scan_task(
     if (!_pending_tasks.empty()) {
         // Do not submit more pending scanners after the shared LIMIT is 
exhausted while
         // completed or in-flight tasks can still make progress. If neither 
exists, allow pending
-        // scanners to be submitted so they can report EOS and wake the 
pipeline task.
+        // scanners to be submitted so they can complete without reading and 
wake the pipeline
+        // task, which finishes the context once nothing is in flight.
         if (_is_shared_scan_limit_exhausted() &&
             (_in_flight_tasks_num != 0 || !_completed_tasks.empty())) {
             return nullptr;
diff --git a/be/src/runtime/exec_env.h b/be/src/runtime/exec_env.h
index 8d30f60258a..be7d92c4fff 100644
--- a/be/src/runtime/exec_env.h
+++ b/be/src/runtime/exec_env.h
@@ -348,6 +348,7 @@ public:
         _inverted_index_query_cache = inverted_index_query_cache;
     }
     void set_process_profile(ProcessProfile* pp) { this->_process_profile = 
pp; }
+    void set_id_manager(IdManager* id_manager) { this->_id_manager = 
id_manager; }
     void set_delete_bitmap_agg_cache(DeleteBitmapAggCache* c) { 
_delete_bitmap_agg_cache = c; }
     void set_storage_page_cache(StoragePageCache* c) { 
this->_storage_page_cache = c; }
     void set_ann_index_ivf_list_cache(AnnIndexIVFListCache* c) {
diff --git a/be/src/service/internal_service.cpp 
b/be/src/service/internal_service.cpp
index a2014b7bb3d..d64349a6d29 100644
--- a/be/src/service/internal_service.cpp
+++ b/be/src/service/internal_service.cpp
@@ -2063,9 +2063,7 @@ void 
PInternalService::multiget_data_v2(google::protobuf::RpcController* control
                         MonotonicStopWatch watch;
                         watch.start();
                         brpc::ClosureGuard closure_guard(done);
-                        response->mutable_status()->set_status_code(0);
-                        Status st = 
RowIdStorageReader::read_by_rowids(*request, response);
-                        st.to_protobuf(response->mutable_status());
+                        RowIdStorageReader::read_by_rowids_for_rpc(*request, 
response);
                         LOG(INFO) << "multiget_data finished, cost(us):"
                                   << watch.elapsed_time() / 1000;
                         return true;
diff --git a/be/test/exec/rowid_fetcher_test.cpp 
b/be/test/exec/rowid_fetcher_test.cpp
index 77bf26a98d1..b0478cfc4ab 100644
--- a/be/test/exec/rowid_fetcher_test.cpp
+++ b/be/test/exec/rowid_fetcher_test.cpp
@@ -22,13 +22,22 @@
 #include <string>
 #include <vector>
 
+#include "agent/cgroup_cpu_ctl.h"
+#include "common/exception.h"
 #include "exec/operator/file_scan_operator.h"
 #include "exec/scan/file_scanner_v2.h"
+#include "exec/scan/scanner_scheduler.h"
 #include "format_v2/column_mapper.h"
 #include "format_v2/table/hive_reader.h"
+#include "gen_cpp/internal_service.pb.h"
 #include "runtime/descriptor_helper.h"
 #include "runtime/descriptors.h"
+#include "runtime/exec_env.h"
 #include "runtime/runtime_state.h"
+#include "storage/id_manager.h"
+#include "storage/rowset_id.h"
+#include "util/countdown_latch.h"
+#include "util/defer_op.h"
 
 namespace doris {
 
@@ -429,4 +438,67 @@ TEST_F(SubmitExternalScanTasksTest, 
ThrownExceptionReachesTheCaller) {
     EXPECT_NE(result.to_string().find("scanner threw"), std::string::npos);
 }
 
+// multiget_data_v2 runs read_by_rowids() on the remote scan scheduler, which 
by default is a
+// ThreadPool whose worker has no exception boundary. A request whose file 
mapping exists but
+// whose slot carries a PTypeNode this BE cannot decode makes SlotDescriptor's 
constructor throw;
+// the RPC helper must turn that into an error status on the response instead 
of letting the
+// exception leave the worker and terminate the process.
+TEST_F(RowIdStorageReaderTest, 
RpcHelperReportsThrownExceptionOnThreadPoolPath) {
+    IdManager id_manager;
+    ExecEnv* env = ExecEnv::GetInstance();
+    IdManager* saved_id_manager = env->get_id_manager();
+    env->set_id_manager(&id_manager);
+    Defer restore_id_manager {[&] { env->set_id_manager(saved_id_manager); }};
+
+    PMultiGetRequestV2 request;
+    request.mutable_query_id()->set_hi(68610);
+    request.mutable_query_id()->set_lo(1);
+    request.set_wg_id(1);
+    auto id_file_map = id_manager.add_id_file_map(request.query_id(), 1024);
+    const uint32_t file_id = id_file_map->get_file_mapping_id(
+            std::make_shared<FileMapping>(/*tablet_id=*/1, RowsetId(), 
/*segment_id=*/0));
+
+    PRequestBlockDesc* block_desc = request.add_request_block_descs();
+    block_desc->add_file_id(file_id);
+    block_desc->add_row_id(0);
+    PSlotDescriptor* slot = block_desc->add_slots();
+    slot->set_id(0);
+    slot->set_parent(0);
+    slot->set_column_pos(0);
+    slot->set_byte_offset(0);
+    slot->set_null_indicator_byte(0);
+    slot->set_null_indicator_bit(-1);
+    slot->set_col_name("c");
+    slot->set_slot_idx(0);
+    // A type node kind unknown to this BE, as a newer FE could send.
+    slot->mutable_slot_type()->add_types()->set_type(99);
+
+    // The worker captures the response and the latch by reference. Declare 
them before the
+    // scheduler so every exit, including a failed wait below, joins the 
worker in stop_scheduler
+    // before either captured object is destroyed.
+    PMultiGetResponseV2 response;
+    CountDownLatch finished(1);
+    std::shared_ptr<CgroupCpuCtl> cgroup_cpu_ctl = 
std::make_shared<CgroupV2CpuCtl>(1);
+    ThreadPoolSimplifiedScanScheduler scheduler("rowid_rpc_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(1, 1, 4, 1).ok());
+    Defer stop_scheduler {[&] { scheduler.stop(); }};
+
+    ASSERT_TRUE(scheduler
+                        .submit_scan_task(SimplifiedScanTask(
+                                                  [&]() {
+                                                      
RowIdStorageReader::read_by_rowids_for_rpc(
+                                                              request, 
&response);
+                                                      finished.count_down();
+                                                      return true;
+                                                  },
+                                                  nullptr, nullptr),
+                                          "rowid_rpc_test_task")
+                        .ok());
+    ASSERT_TRUE(finished.wait_for(std::chrono::seconds(30)));
+
+    EXPECT_NE(response.status().status_code(), 0);
+    ASSERT_EQ(response.status().error_msgs_size(), 1);
+    EXPECT_NE(response.status().error_msgs(0).find("invalid TTypeNodeType"), 
std::string::npos);
+}
+
 } // namespace doris
diff --git a/be/test/exec/scan/scanner_context_test.cpp 
b/be/test/exec/scan/scanner_context_test.cpp
index d60f4e77d62..a9a3eeac3b9 100644
--- a/be/test/exec/scan/scanner_context_test.cpp
+++ b/be/test/exec/scan/scanner_context_test.cpp
@@ -705,6 +705,140 @@ TEST_F(ScannerContextTest, thread_pool_admission_state) {
     EXPECT_EQ(scanner_context->try_get_next_scan_task(context_transfer_lock), 
nullptr);
 }
 
+TEST_F(ScannerContextTest, thread_pool_admission_ignores_shared_limit) {
+    const int parallel_tasks = 1;
+    const int64_t limit = 100;
+    auto scan_operator = std::make_unique<OlapScanOperatorX>(obj_pool.get(), 
tnode, 0, *descs,
+                                                             parallel_tasks, 
TQueryCacheParam {});
+    auto olap_scan_local_state =
+            OlapScanLocalState::create_unique(state.get(), 
scan_operator.get());
+
+    OlapScanner::Params scanner_params;
+    scanner_params.state = state.get();
+    scanner_params.profile = profile.get();
+    scanner_params.limit = limit;
+    scanner_params.key_ranges = std::vector<OlapScanRange*>();
+    std::shared_ptr<Scanner> scanner =
+            OlapScanner::create_shared(olap_scan_local_state.get(), 
std::move(scanner_params));
+    std::list<std::shared_ptr<ScannerDelegate>> scanners {
+            std::make_shared<ScannerDelegate>(scanner)};
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, limit,
+            scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+
+    std::unique_ptr<MockSimplifiedScanScheduler> scheduler =
+            std::make_unique<MockSimplifiedScanScheduler>(cgroup_cpu_ctl);
+    EXPECT_CALL(*scheduler, 
get_active_threads()).WillRepeatedly(testing::Return(0));
+    EXPECT_CALL(*scheduler, 
get_queue_size()).WillRepeatedly(testing::Return(0));
+    scanner_context->_scanner_scheduler = scheduler.get();
+    scanner_context->_min_scan_concurrency_of_scan_scheduler = 20;
+    scanner_context->_max_scan_concurrency = 4;
+
+    std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+    scanner_context->_pending_tasks = std::stack<std::shared_ptr<ScanTask>>();
+    scanner_context->_completed_tasks.clear();
+    scanner_context->push_pending_scan_task(
+            
std::make_shared<ScanTask>(std::make_shared<ScannerDelegate>(scanner)), 
transfer_lock);
+
+    // The shared LIMIT is exhausted while a task is in flight and a completed 
result waits for the
+    // operator. Admission only applies the concurrency limit: the pending 
scanner is still
+    // admitted, and Scanner::get_block_after_projects() returns an empty 
block without reading
+    // (and without EOS, which the scan operator decides from the LIMIT). 
Holding it back based on
+    // the in-flight or completed tasks is what can leave a context with 
nothing to wake the
+    // operator.
+    shared_limit.store(0);
+    scanner_context->_in_flight_tasks_num = 1;
+    auto completed_task = 
std::make_shared<ScanTask>(std::make_shared<ScannerDelegate>(scanner));
+    completed_task->set_state(ScanTask::State::IN_FLIGHT);
+    completed_task->set_state(ScanTask::State::EOS);
+    scanner_context->_completed_tasks.push_back(completed_task);
+    EXPECT_TRUE(scanner_context->can_admit_scan_task(transfer_lock));
+    auto admitted_task = 
scanner_context->try_get_next_scan_task(transfer_lock);
+    ASSERT_NE(admitted_task, nullptr);
+    EXPECT_EQ(scanner_context->_in_flight_tasks_num, 2);
+    EXPECT_TRUE(scanner_context->_pending_tasks.empty());
+}
+
+TEST_F(ScannerContextTest, 
exhausted_shared_limit_parks_empty_task_until_in_flight_drains) {
+    const int parallel_tasks = 2;
+    const int64_t limit = 10;
+    auto scan_operator = std::make_unique<OlapScanOperatorX>(obj_pool.get(), 
tnode, 0, *descs,
+                                                             parallel_tasks, 
TQueryCacheParam {});
+    auto olap_scan_local_state =
+            OlapScanLocalState::create_unique(state.get(), 
scan_operator.get());
+
+    OlapScanner::Params scanner_params;
+    scanner_params.state = state.get();
+    scanner_params.profile = profile.get();
+    scanner_params.limit = limit;
+    scanner_params.key_ranges = std::vector<OlapScanRange*>();
+    std::shared_ptr<Scanner> scanner =
+            OlapScanner::create_shared(olap_scan_local_state.get(), 
std::move(scanner_params));
+    std::list<std::shared_ptr<ScannerDelegate>> scanners {
+            std::make_shared<ScannerDelegate>(scanner), 
std::make_shared<ScannerDelegate>(scanner)};
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, limit,
+            scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+    scanner_context->_newly_create_free_blocks_num = 
newly_create_free_blocks_num.get();
+    scanner_context->_scanner_memory_used_counter = 
scanner_memory_used_counter.get();
+
+    // Both schedulers resubmit through schedule_scan_task(); an exhausted 
LIMIT must not reach it.
+    std::unique_ptr<MockSimplifiedScanScheduler> scheduler =
+            std::make_unique<MockSimplifiedScanScheduler>(cgroup_cpu_ctl);
+    EXPECT_CALL(*scheduler, schedule_scan_task(testing::_, testing::_, 
testing::_)).Times(0);
+    scanner_context->_scanner_scheduler = scheduler.get();
+
+    // Scanners of a parallel peer instance produced the whole operator-wide 
LIMIT while this
+    // context has scanner B blocked in I/O. Scanner A returned fewer rows 
than this instance's
+    // LIMIT and now completes with an empty block but without EOS, as the 
LIMIT is not EOS.
+    auto task_a = std::make_shared<ScanTask>(scanners.front());
+    auto task_b = std::make_shared<ScanTask>(scanners.back());
+    {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        scanner_context->_pending_tasks = 
std::stack<std::shared_ptr<ScanTask>>();
+        shared_limit.store(0);
+        task_a->set_state(ScanTask::State::IN_FLIGHT);
+        task_b->set_state(ScanTask::State::IN_FLIGHT);
+        scanner_context->_in_flight_tasks_num = 2;
+    }
+    task_a->cached_block = Block::create_unique();
+    task_a->set_state(ScanTask::State::COMPLETED);
+    scanner_context->push_completed_scan_task(task_a);
+
+    // Consuming the empty result parks A instead of resubmitting it: A would 
only complete empty
+    // again for as long as B's I/O lasts. B's completion is what wakes the 
operator next.
+    Block block;
+    bool eos = false;
+    Status st = scanner_context->get_block_from_queue(state.get(), &block, 
&eos, 0);
+    ASSERT_TRUE(st.ok()) << st.to_string();
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(block.rows(), 0);
+    {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        EXPECT_FALSE(scanner_context->done());
+        EXPECT_EQ(scanner_context->_in_flight_tasks_num, 1);
+        EXPECT_EQ(task_a->_state, ScanTask::State::PENDING);
+        ASSERT_EQ(scanner_context->_pending_tasks.size(), 1);
+        EXPECT_EQ(scanner_context->_pending_tasks.top(), task_a);
+    }
+
+    // B drains with no more rows to add. Nothing is in flight anymore, so the 
context finishes
+    // without ever running A again.
+    task_b->cached_block = Block::create_unique();
+    task_b->set_state(ScanTask::State::COMPLETED);
+    scanner_context->push_completed_scan_task(task_b);
+    st = scanner_context->get_block_from_queue(state.get(), &block, &eos, 0);
+    ASSERT_TRUE(st.ok()) << st.to_string();
+    EXPECT_TRUE(eos);
+    {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        EXPECT_TRUE(scanner_context->_is_finished);
+        EXPECT_EQ(scanner_context->_in_flight_tasks_num, 0);
+        ASSERT_EQ(scanner_context->_pending_tasks.size(), 1);
+        EXPECT_EQ(scanner_context->_pending_tasks.top(), task_a);
+    }
+}
+
 TEST_F(ScannerContextTest, thread_pool_admission_refreshes_adaptive_limit) {
     const int parallel_tasks = 2;
     auto scan_operator = std::make_unique<OlapScanOperatorX>(obj_pool.get(), 
tnode, 0, *descs,
diff --git a/be/test/exec/scan/scanner_late_arrival_rf_test.cpp 
b/be/test/exec/scan/scanner_late_arrival_rf_test.cpp
index a21e7f47bda..5989ce1e7f0 100644
--- a/be/test/exec/scan/scanner_late_arrival_rf_test.cpp
+++ b/be/test/exec/scan/scanner_late_arrival_rf_test.cpp
@@ -19,6 +19,7 @@
 #include <gtest/gtest.h>
 
 #include <atomic>
+#include <functional>
 #include <latch>
 #include <list>
 #include <thread>
@@ -62,6 +63,9 @@ public:
             : Scanner(state, local_state, limit, profile), 
_allow_padding(allow_padding) {}
 
     void add_block(Block block) { _blocks.push_back(std::move(block)); }
+    size_t unread_blocks() const { return _blocks.size(); }
+    // Runs after each source block is handed out, to model a peer scanner 
acting mid-read.
+    void set_after_read_hook(std::function<void()> hook) { _after_read_hook = 
std::move(hook); }
 
 protected:
     bool _can_merge_padding_blocks(const Block& /*left*/, const Block& 
/*right*/) const override {
@@ -76,12 +80,16 @@ protected:
         *eof = false;
         block->swap(_blocks.front());
         _blocks.pop_front();
+        if (_after_read_hook) {
+            _after_read_hook();
+        }
         return Status::OK();
     }
 
 private:
     bool _allow_padding = true;
     std::list<Block> _blocks;
+    std::function<void()> _after_read_hook;
 };
 
 class FakeTablet final : public BaseTablet {
@@ -739,6 +747,45 @@ TEST_F(ScannerLateArrivalRfTest, 
olap_scanner_releases_resources_before_open) {
     prepared_scanner->update_realtime_counters();
 }
 
+// Admission does not look at the shared LIMIT, so a pending scanner is still 
scheduled after its
+// peers have exhausted it. The scanner returns an empty block on that call 
instead of reading its
+// range, and does not report EOS: the scan operator decides that from the 
LIMIT.
+TEST(ScannerProjectionTest, 
shared_limit_exhausted_before_call_returns_empty_block_without_eos) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(4);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0, 1}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({2, 3, 4}));
+
+    // Peer scanners returned the whole LIMIT before this scanner ran.
+    op->_shared_scan_limit.fetch_sub(4);
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(output.rows(), 0);
+    EXPECT_EQ(scanner.unread_blocks(), 2);
+    EXPECT_EQ(op->_shared_scan_limit.load(), 0);
+}
+
 TEST(ScannerProjectionTest, 
merges_padding_block_when_limit_eos_without_extra_flag) {
     ObjectPool pool;
     auto data_type = std::make_shared<DataTypeInt32>();
@@ -854,4 +901,442 @@ TEST(ScannerProjectionTest, 
projects_incompatible_blocks_before_reading_the_next
     EXPECT_EQ(final_output.rows(), 0);
 }
 
+// With parallel scanners, the scan context may finish as soon as the shared 
LIMIT counter is
+// exhausted. Count only the rows actually produced: after the first call, one 
row is still held
+// in padding, so a peer returning four rows leaves one row for this scanner 
to produce.
+TEST(ScannerProjectionTest, shared_limit_counts_only_emitted_rows) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(10);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    // The first block is below the padding threshold and is held while the 
second block, which
+    // reaches the threshold on its own, is emitted.
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({1, 2, 3, 4, 
5}));
+
+    Block first_output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &first_output, 
&eos).ok());
+    EXPECT_FALSE(eos);
+    ASSERT_EQ(first_output.rows(), 5);
+    EXPECT_EQ(first_output.get_by_position(0).column->get_int(0), 1);
+    // Only the five returned rows consume the shared LIMIT; the held row does 
not.
+    EXPECT_EQ(op->_shared_scan_limit.load(), 5);
+
+    // A peer returns four more rows. Counting the held row before it is 
produced would let the
+    // context finish at nine output rows instead of scheduling this scanner 
again.
+    TestScanner peer(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(peer.init(&state, {}).ok());
+    peer.add_block(ColumnHelper::create_block<DataTypeInt32>({6, 7, 8, 9}));
+    Block peer_output;
+    bool peer_eos = false;
+    ASSERT_TRUE(peer.get_block_after_projects(&state, &peer_output, 
&peer_eos).ok());
+    EXPECT_EQ(peer_output.rows(), 4);
+    EXPECT_EQ(op->_shared_scan_limit.load(), 1);
+
+    // The held row already satisfies the one remaining row, so flush it 
without reading on. The
+    // reader still has no EOS to report; the operator stops the scanner after 
cutting the LIMIT.
+    Block final_output;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &final_output, 
&eos).ok());
+    EXPECT_FALSE(eos);
+    ASSERT_EQ(final_output.rows(), 1);
+    EXPECT_EQ(final_output.get_by_position(0).column->get_int(0), 0);
+    EXPECT_EQ(op->_shared_scan_limit.load(), 0);
+}
+
+// Padding stays unreturned when a peer has already produced the whole LIMIT. 
It never consumed
+// shared credit, so skipping it cannot leave the operator short of rows, and 
the scanner leaves
+// EOS to the operator's LIMIT cut.
+TEST(ScannerProjectionTest, 
shared_limit_drops_padding_after_peer_satisfies_limit) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(10);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({1, 2, 3, 4, 
5}));
+    Block first_output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &first_output, 
&eos).ok());
+    ASSERT_EQ(first_output.rows(), 5);
+    ASSERT_EQ(op->_shared_scan_limit.load(), 5);
+
+    TestScanner peer(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(peer.init(&state, {}).ok());
+    peer.add_block(ColumnHelper::create_block<DataTypeInt32>({6, 7, 8, 9, 
10}));
+    Block peer_output;
+    bool peer_eos = false;
+    ASSERT_TRUE(peer.get_block_after_projects(&state, &peer_output, 
&peer_eos).ok());
+    ASSERT_EQ(peer_output.rows(), 5);
+    ASSERT_EQ(op->_shared_scan_limit.load(), 0);
+
+    Block skipped_output;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &skipped_output, 
&eos).ok());
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(skipped_output.rows(), 0);
+    EXPECT_EQ(op->_shared_scan_limit.load(), 0);
+}
+
+// LIMIT 1 must not turn padding into a long scan through a selective tail 
just to reach half a
+// batch. The first surviving row is returned and charged before the next 
source block is read.
+TEST(ScannerProjectionTest, shared_limit_flushes_padding_before_reading_more) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(1);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({1, 2, 3, 4, 
5}));
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    ASSERT_EQ(output.rows(), 1);
+    EXPECT_EQ(output.get_by_position(0).column->get_int(0), 0);
+    EXPECT_EQ(scanner.unread_blocks(), 1);
+    EXPECT_EQ(op->_shared_scan_limit.load(), 0);
+}
+
+// Several small source blocks can satisfy the LIMIT together without giving 
up the normal
+// small-block merge or scanning one more source block to reach the half-batch 
target.
+TEST(ScannerProjectionTest, shared_limit_flushes_merged_padding_at_limit) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(2);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({1}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({2, 3, 4, 5}));
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    ASSERT_EQ(output.rows(), 2);
+    EXPECT_EQ(output.get_by_position(0).column->get_int(0), 0);
+    EXPECT_EQ(output.get_by_position(0).column->get_int(1), 1);
+    EXPECT_EQ(scanner.unread_blocks(), 1);
+    EXPECT_EQ(op->_shared_scan_limit.load(), 0);
+}
+
+// The no-projection path must charge the returned block once as well; it does 
not use padding.
+TEST(ScannerProjectionTest, 
shared_limit_counts_returned_rows_without_projection) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    op->_output_tuple_desc = row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(2);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0, 1, 2}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({3}));
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(output.rows(), 3);
+    EXPECT_EQ(scanner.unread_blocks(), 1);
+    EXPECT_EQ(op->_shared_scan_limit.load(), -1);
+}
+
+// File-local projections that cannot pad still use the same output-only 
shared accounting.
+TEST(ScannerProjectionTest, shared_limit_counts_returned_rows_without_padding) 
{
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(2);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile, false);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0, 1, 2}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({3}));
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(output.rows(), 3);
+    EXPECT_EQ(scanner.unread_blocks(), 1);
+    EXPECT_EQ(op->_shared_scan_limit.load(), -1);
+}
+
+// A selective scan under a large shared LIMIT still coalesces small blocks: 
one call reads several
+// source blocks and returns them as one block, instead of handing every 
surviving row to the
+// operator alone.
+TEST(ScannerProjectionTest, shared_limit_keeps_padding_small_blocks) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(100);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    for (int i = 0; i < 6; ++i) {
+        scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({i}));
+    }
+
+    // Four single-row blocks fill half a batch and are returned together.
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    ASSERT_EQ(output.rows(), 4);
+    for (int i = 0; i < 4; ++i) {
+        EXPECT_EQ(output.get_by_position(0).column->get_int(i), i);
+    }
+    EXPECT_EQ(op->_shared_scan_limit.load(), 96);
+
+    // The tail is returned with eos; nothing is held across the calls.
+    Block tail;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &tail, &eos).ok());
+    EXPECT_TRUE(eos);
+    ASSERT_EQ(tail.rows(), 2);
+    EXPECT_EQ(op->_shared_scan_limit.load(), 94);
+}
+
+// Concurrent scanners may produce more than the shared LIMIT before the 
operator cuts their
+// output. A scanner initialized after that must still retain the pointer and 
stop without reading.
+TEST(ScannerProjectionTest, 
shared_limit_observed_by_scanner_initialized_under_negative_counter) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(8);
+    // Peers produced nine rows for LIMIT 8 before the operator made its final 
cut.
+    op->_shared_scan_limit.store(-1);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    ASSERT_EQ(scanner._shared_scan_limit, &op->_shared_scan_limit);
+
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0, 1, 2}));
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(output.rows(), 0);
+    EXPECT_EQ(op->_shared_scan_limit.load(), -1);
+    EXPECT_EQ(scanner.unread_blocks(), 1);
+}
+
+// Without a SQL LIMIT the scanner gets no counter, whatever the counter holds.
+TEST(ScannerProjectionTest, no_shared_limit_without_sql_limit) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    ASSERT_EQ(op->limit(), -1);
+    op->_shared_scan_limit.store(5);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    EXPECT_EQ(scanner._shared_scan_limit, nullptr);
+}
+
+// Once its own rows exhaust the shared LIMIT the scanner stops reading, even 
though its reader
+// still has data. It does not report EOS for that: the next call returns an 
empty block and the
+// scan operator stops the scanner after cutting the LIMIT.
+TEST(ScannerProjectionTest, 
shared_limit_exhausted_by_own_rows_stops_reading_without_eos) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(3);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({0, 1, 2, 3, 
4}));
+    scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({5, 6, 7, 8, 
9}));
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(output.rows(), 5);
+    EXPECT_EQ(scanner.unread_blocks(), 1);
+    EXPECT_EQ(op->_shared_scan_limit.load(), -2);
+
+    Block next_output;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &next_output, 
&eos).ok());
+    EXPECT_FALSE(eos);
+    EXPECT_EQ(next_output.rows(), 0);
+    EXPECT_EQ(scanner.unread_blocks(), 1);
+    EXPECT_EQ(op->_shared_scan_limit.load(), -2);
+}
+
+// A peer may exhaust the shared LIMIT while this scanner is still padding 
small blocks. The read
+// loop stops at once, the rows already held are returned, and EOS is again 
left to the operator.
+TEST(ScannerProjectionTest, 
shared_limit_exhausted_by_peer_mid_read_returns_held_rows_without_eos) {
+    ObjectPool pool;
+    auto data_type = std::make_shared<DataTypeInt32>();
+    auto row_descriptor = MockRowDescriptor({data_type}, &pool);
+
+    MockRuntimeState state;
+    state._batch_size = 8;
+
+    auto op = std::make_shared<MockScanOperatorX>();
+    op->_row_descriptor = row_descriptor;
+    auto& projection = op->set_projection_for_test(
+            MockRowDescriptor(std::vector<DataTypePtr> {data_type}, &pool));
+    op->_output_tuple_desc = 
projection.output_row_descriptor.tuple_descriptors()[0];
+    op->set_limit_for_test(10);
+
+    auto local_state = std::make_shared<MockScanLocalState>(&state, op.get());
+    local_state->_projections = MockSlotRef::create_mock_contexts(0, 
data_type);
+
+    RuntimeProfile profile("scanner");
+    TestScanner scanner(&state, local_state.get(), -1, &profile);
+    ASSERT_TRUE(scanner.init(&state, {}).ok());
+    for (int i = 0; i < 5; ++i) {
+        scanner.add_block(ColumnHelper::create_block<DataTypeInt32>({i}));
+    }
+    // A peer produces the whole LIMIT right after this scanner reads its 
first block.
+    bool peer_done = false;
+    scanner.set_after_read_hook([&] {
+        if (!peer_done) {
+            peer_done = true;
+            op->_shared_scan_limit.fetch_sub(10);
+        }
+    });
+
+    Block output;
+    bool eos = false;
+    ASSERT_TRUE(scanner.get_block_after_projects(&state, &output, &eos).ok());
+    EXPECT_FALSE(eos);
+    ASSERT_EQ(output.rows(), 1);
+    EXPECT_EQ(output.get_by_position(0).column->get_int(0), 0);
+    EXPECT_EQ(scanner.unread_blocks(), 4);
+    EXPECT_EQ(op->_shared_scan_limit.load(), -1);
+}
+
 } // namespace doris
diff --git a/be/test/runtime/workload_group/workload_group_manager_test.cpp 
b/be/test/runtime/workload_group/workload_group_manager_test.cpp
index db047257af5..a7f4543dc81 100644
--- a/be/test/runtime/workload_group/workload_group_manager_test.cpp
+++ b/be/test/runtime/workload_group/workload_group_manager_test.cpp
@@ -41,6 +41,7 @@
 #include "common/status.h"
 #include "cpp/sync_point.h"
 #include "exec/pipeline/dependency.h"
+#include "exec/scan/scanner_scheduler.h"
 #include "exec/spill/spill_file_manager.h"
 #include "load/memtable/memtable_memory_limiter.h"
 #include "runtime/exec_env.h"
@@ -1489,6 +1490,62 @@ TEST_F(WorkloadGroupManagerTest, 
FailedInternalGroupSetupCancelsAdaptiveFlush) {
     controller->stop();
 }
 
+TEST_F(WorkloadGroupManagerTest, DefaultScanSchedulersUseThreadPool) {
+    // Check the registered defaults, which do not depend on the be.conf 
loaded by the UT runner.
+    const auto& fields = *config::Register::_s_field_map;
+    EXPECT_STREQ("false", 
fields.at("enable_task_executor_in_internal_table").defval);
+    EXPECT_STREQ("false", 
fields.at("enable_task_executor_in_external_table").defval);
+
+    const auto saved_config = std::make_tuple(
+            config::enable_adaptive_flush_threads, 
config::enable_task_executor_in_internal_table,
+            config::enable_task_executor_in_external_table, 
config::pipeline_executor_size,
+            config::blocking_pipeline_executor_size, 
config::doris_scanner_thread_pool_thread_num,
+            config::doris_max_remote_scanner_thread_pool_thread_num,
+            config::doris_scanner_min_thread_pool_thread_num, 
config::min_active_scan_threads,
+            config::min_active_file_scan_threads, 
config::flush_thread_num_per_store);
+    Defer restore {[&] {
+        std::tie(config::enable_adaptive_flush_threads,
+                 config::enable_task_executor_in_internal_table,
+                 config::enable_task_executor_in_external_table, 
config::pipeline_executor_size,
+                 config::blocking_pipeline_executor_size,
+                 config::doris_scanner_thread_pool_thread_num,
+                 config::doris_max_remote_scanner_thread_pool_thread_num,
+                 config::doris_scanner_min_thread_pool_thread_num, 
config::min_active_scan_threads,
+                 config::min_active_file_scan_threads, 
config::flush_thread_num_per_store) =
+                saved_config;
+    }};
+    config::enable_adaptive_flush_threads = false;
+    config::enable_task_executor_in_internal_table = false;
+    config::enable_task_executor_in_external_table = false;
+    config::pipeline_executor_size = 1;
+    config::blocking_pipeline_executor_size = 1;
+    config::doris_scanner_thread_pool_thread_num = 1;
+    config::doris_max_remote_scanner_thread_pool_thread_num = 1;
+    config::doris_scanner_min_thread_pool_thread_num = 1;
+    config::min_active_scan_threads = 1;
+    config::min_active_file_scan_threads = 1;
+    config::flush_thread_num_per_store = 1;
+
+    ASSERT_TRUE(_wg_manager->create_internal_wg().ok());
+    Defer stop_schedulers {[&] {
+        for (auto& entry : _wg_manager->_workload_groups) {
+            entry.second->try_stop_schedulers();
+            entry.second->destroy_schedulers();
+        }
+    }};
+    ASSERT_EQ(_wg_manager->_workload_groups.size(), 1);
+    auto wg = _wg_manager->_workload_groups.begin()->second;
+
+    TaskScheduler* exec_sched = nullptr;
+    ScannerScheduler* scan_sched = nullptr;
+    ScannerScheduler* remote_scan_sched = nullptr;
+    wg->get_query_scheduler(&exec_sched, &scan_sched, &remote_scan_sched);
+    ASSERT_NE(scan_sched, nullptr);
+    ASSERT_NE(remote_scan_sched, nullptr);
+    EXPECT_NE(dynamic_cast<ThreadPoolSimplifiedScanScheduler*>(scan_sched), 
nullptr);
+    
EXPECT_NE(dynamic_cast<ThreadPoolSimplifiedScanScheduler*>(remote_scan_sched), 
nullptr);
+}
+
 // Exercise the actual registration/cancellation paths without starting query 
schedulers.
 TEST_F(WorkloadGroupManagerTest, 
AdaptiveFlushRegistrationSurvivesIdChangeAndReuse) {
     auto* env = ExecEnv::GetInstance();


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

Reply via email to