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

yiguolei 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 b6a52ffacd0 [improvement](be) Refactor thread-pool scan scheduling 
(#67070)
b6a52ffacd0 is described below

commit b6a52ffacd0c75329b54a916ddd44f43212d3bc0
Author: Jerry Hu <[email protected]>
AuthorDate: Mon Aug 31 09:51:11 2026 +0800

    [improvement](be) Refactor thread-pool scan scheduling (#67070)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: None
    
    Problem Summary:
    
    The ThreadPool scan scheduler submitted one runnable per scanner and
    serialized scheduling through a scheduler-wide lock. With many scanners,
    this inflated queue occupancy and coupled ThreadPool admission to
    TaskExecutor-specific scheduling logic.
    
    This change queues at most one runnable per `ScannerContext`, admits
    scanner tasks under the Context transfer lock, keeps ThreadPool
    submission failures local to the Context scheduler, and checks terminal
    Context state before rescheduling. TaskExecutor remains the default scan
    scheduler, and this change does not modify generic ThreadPool behavior.
    
    The ThreadPool path also gets `VLOG_DEBUG` traces on admission, refusal
    and runnable submission, and `ScannerContext::debug_string()` now
    reports `_is_context_queued` and `expected_scanners`, so a stalled
    Context can be diagnosed from logs. Unit tests run the whole Context
    chain (admit -> execute -> publish -> consume -> re-admit) on a real
    `ThreadPoolSimplifiedScanScheduler`, prove that a queued runnable is not
    duplicated, and cover the stopped-scheduler branch.
    
    Admission on the ThreadPool path keeps the two limits `_get_margin()`
    applied before: an adaptive allocation of zero is honoured as the
    ceiling (only the `current_concurrency == 0` escape admits one task),
    and once the pool has no slack (`active + queued >=
    min_active_scan_threads`) a Context is held at `max(1,
    min_scanners_concurrency)` instead of ramping to
    `max_scanners_concurrency`.
    
    Validation:
    
    - `./run-be-ut.sh --run --filter='ScannerContextTest.*' -j16` (41 tests
    passed)
    - `./run-be-ut.sh --run --filter='ThreadPoolTest.*' -j16` (18 tests
    passed)
    - `./build-support/check-format.sh`
    - `./build-support/check-build-hygiene.sh`
    - `git diff --check`
    
    `run-clang-tidy.sh` was also attempted, but the local clang-tidy
    environment cannot analyze the files because the configured toolchain
    cannot find `stddef.h` and current `be/src/core/types.h` reports a
    pre-existing unmatched `NOLINTEND`.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [ ] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason <!-- Add your reason?  -->
    
    - Behavior changed:
        - [ ] No.
    - [x] Yes. The opt-in ThreadPool scan scheduler queues and admits work
    per `ScannerContext`.
    
    - Does this need documentation?
        - [x] No.
    - [ ] Yes. <!-- Add document PR link here. eg:
    https://github.com/apache/doris-website/pull/1214 -->
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label <!-- Add branch pick label that this PR
    should merge into -->
---
 be/src/exec/scan/scanner_context.cpp           | 130 ++++-
 be/src/exec/scan/scanner_context.h             |  73 ++-
 be/src/exec/scan/scanner_scheduler.cpp         |  30 +-
 be/src/exec/scan/scanner_scheduler.h           |   9 +-
 be/src/exec/scan/simplified_scan_scheduler.cpp | 105 +++-
 be/test/exec/scan/scanner_context_test.cpp     | 769 ++++++++++++++++++++++++-
 6 files changed, 1073 insertions(+), 43 deletions(-)

diff --git a/be/src/exec/scan/scanner_context.cpp 
b/be/src/exec/scan/scanner_context.cpp
index c3f1cd55503..c4180cfcc2a 100644
--- a/be/src/exec/scan/scanner_context.cpp
+++ b/be/src/exec/scan/scanner_context.cpp
@@ -327,9 +327,9 @@ void ScannerContext::return_free_block(BlockUPtr block) {
 Status ScannerContext::submit_scan_task(std::shared_ptr<ScanTask> scan_task,
                                         std::unique_lock<std::mutex>& 
/*transfer_lock*/) {
     // increase _num_finished_scanners no matter the scan_task is submitted 
successfully or not.
-    // since if submit failed, it will be added back by 
ScannerContext::push_back_scan_task
+    // since if submit failed, it will be added back by 
ScannerContext::push_completed_scan_task
     // and _num_finished_scanners will be reduced.
-    // if submit succeed, it will be also added back by 
ScannerContext::push_back_scan_task
+    // if submit succeed, it will be also added back by 
ScannerContext::push_completed_scan_task
     // see ScannerScheduler::_scanner_scan.
     _in_flight_tasks_num++;
     return _scanner_scheduler->submit(shared_from_this(), scan_task);
@@ -339,7 +339,7 @@ void ScannerContext::clear_free_blocks() {
     clear_blocks(_free_blocks);
 }
 
-void ScannerContext::push_back_scan_task(std::shared_ptr<ScanTask> scan_task) {
+void ScannerContext::push_completed_scan_task(std::shared_ptr<ScanTask> 
scan_task) {
     if (scan_task->status_ok()) {
         if (scan_task->cached_block && scan_task->cached_block->rows() > 0) {
             Status st = validate_block_schema(scan_task->cached_block.get());
@@ -349,6 +349,8 @@ void 
ScannerContext::push_back_scan_task(std::shared_ptr<ScanTask> scan_task) {
         }
     }
 
+    // Publishing the result and releasing its in-flight slot must be atomic. 
Otherwise a worker
+    // could observe an available slot before the operator can observe this 
completed task.
     std::lock_guard<std::mutex> l(_transfer_lock);
     if (!scan_task->status_ok()) {
         _process_status = scan_task->get_status();
@@ -407,11 +409,6 @@ Status ScannerContext::get_block_from_queue(RuntimeState* 
state, Block* block, b
         if (scan_task->is_eos()) {
             // 1. if eos, record a finished scanner.
             _num_finished_scanners++;
-            
RETURN_IF_ERROR(_scanner_scheduler->schedule_scan_task(shared_from_this(), 
nullptr, l));
-        } else {
-            scan_task->set_state(ScanTask::State::IN_FLIGHT);
-            RETURN_IF_ERROR(
-                    _scanner_scheduler->schedule_scan_task(shared_from_this(), 
scan_task, l));
         }
     }
 
@@ -420,6 +417,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) {
+        // 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
+        // a queue-capacity error even though there is no result left to drain.
+        RETURN_IF_ERROR(_scanner_scheduler->schedule_scan_task(
+                shared_from_this(), scan_task->is_eos() ? nullptr : scan_task, 
l));
     }
 
     *eos = done();
@@ -541,15 +544,16 @@ std::string ScannerContext::debug_string() {
     return fmt::format(
             "_query_id: {}, id: {}, total scanners: {}, pending tasks: {}, 
completed tasks: {},"
             " _should_stop: {}, _is_finished: {}, free blocks: {},"
-            " limit: {}, _in_flight_tasks_num: {}, remaining_limit: {}, 
_num_running_scanners: {}, "
-            "_max_thread_num: {},"
+            " limit: {}, remaining_limit: {}, _in_flight_tasks_num: {}, 
_is_context_queued: {}, "
+            "_num_finished_scanners: {}, _max_scan_concurrency: {}, 
expected_scanners: {},"
             " _max_bytes_in_queue: {}, _ins_idx: {}, 
_enable_adaptive_scanners: {}, "
             "_mem_share_arb: {}, _scanner_mem_limiter: {}",
             print_id(_query_id), ctx_id, _all_scanners.size(), 
_pending_tasks.size(),
             _completed_tasks.size(), _should_stop, _is_finished, 
_free_blocks.size_approx(), limit,
             _shared_scan_limit->load(std::memory_order_relaxed), 
_in_flight_tasks_num,
-            _num_finished_scanners, _max_scan_concurrency, 
_max_bytes_in_queue, _ins_idx,
-            _enable_adaptive_scanners,
+            _is_context_queued, _num_finished_scanners, _max_scan_concurrency,
+            _enable_adaptive_scanners ? _adaptive_processor->expected_scanners 
: -1,
+            _max_bytes_in_queue, _ins_idx, _enable_adaptive_scanners,
             _enable_adaptive_scanners ? _mem_share_arb->debug_string() : 
"NULL",
             _enable_adaptive_scanners ? _scanner_mem_limiter->debug_string() : 
"NULL");
 }
@@ -562,6 +566,108 @@ bool ScannerContext::_is_shared_scan_limit_exhausted() 
const {
     return limit >= 0 && _shared_scan_limit->load(std::memory_order_acquire) 
<= 0;
 }
 
+bool ScannerContext::is_context_queued(const std::unique_lock<std::mutex>& 
transfer_lock) const {
+    DORIS_CHECK(transfer_lock.owns_lock());
+    return _is_context_queued;
+}
+
+void ScannerContext::set_context_queued(bool queued,
+                                        const std::unique_lock<std::mutex>& 
transfer_lock) {
+    DORIS_CHECK(transfer_lock.owns_lock());
+    DORIS_CHECK(_is_context_queued != queued);
+    _is_context_queued = queued;
+}
+
+void ScannerContext::set_context_failure(const Status& failure,
+                                         const std::unique_lock<std::mutex>& 
transfer_lock) {
+    DORIS_CHECK(transfer_lock.owns_lock());
+    DORIS_CHECK(!failure.ok());
+    _process_status = failure;
+    _is_finished = true;
+    _set_scanner_done();
+}
+
+void ScannerContext::push_pending_scan_task(std::shared_ptr<ScanTask> 
scan_task,
+                                            const 
std::unique_lock<std::mutex>& transfer_lock) {
+    DORIS_CHECK(transfer_lock.owns_lock());
+    DORIS_CHECK(scan_task != nullptr);
+    DORIS_CHECK(scan_task->cached_block == nullptr);
+    DORIS_CHECK(!scan_task->is_eos());
+    // The state transition documents that this is an admission queue, not a 
completed-result queue.
+    scan_task->set_state(ScanTask::State::PENDING);
+    _pending_tasks.push(std::move(scan_task));
+}
+
+bool ScannerContext::can_admit_scan_task(const std::unique_lock<std::mutex>& 
transfer_lock) const {
+    DORIS_CHECK(transfer_lock.owns_lock());
+    if (done() || _pending_tasks.empty()) {
+        return false;
+    }
+
+    int32_t effective_max_concurrency = _max_scan_concurrency;
+    if (_enable_adaptive_scanners) {
+        // expected_scanners is the adaptive ceiling as refreshed by 
_available_pickup_scanner_count().
+        // Zero is a real allocation: MemLimiter::available_scanner_count() 
hands nothing to a later
+        // instance when the node-wide scanner budget is smaller than the 
operator parallelism. It
+        // is not a "not initialized" marker, so it must not fall back to 
_max_scan_concurrency;
+        // the escape below still lets one task make progress.
+        effective_max_concurrency = _adaptive_processor->expected_scanners;
+    }
+    if (low_memory_mode()) {
+        effective_max_concurrency = std::min(effective_max_concurrency, 
low_memory_mode_scanners());
+    }
+    // Mirror the scheduler-wide budget of _get_margin(): once the pool has no 
slack, a Context is
+    // held at its minimum outstanding scanners instead of ramping to its 
maximum. Both counters are
+    // read here under _transfer_lock exactly as the TaskExecutor path reads 
them.
+    if (_scanner_scheduler->get_active_threads() + 
_scanner_scheduler->get_queue_size() >=
+        _min_scan_concurrency_of_scan_scheduler) {
+        effective_max_concurrency =
+                std::min(effective_max_concurrency, std::max(1, 
_min_scan_concurrency));
+    }
+
+    // Completed blocks still occupy a concurrency slot until the operator 
consumes them. Counting
+    // both collections prevents a fast producer from exceeding the 
per-Context scanner limit.
+    const int32_t current_concurrency =
+            cast_set<int32_t>(_completed_tasks.size()) + _in_flight_tasks_num;
+    // Keep one task progressing even if an adaptive limit reaches zero. 
Otherwise no worker can
+    // publish a result and wake the operator to make another scheduling 
decision.
+    return current_concurrency == 0 || current_concurrency < 
effective_max_concurrency;
+}
+
+std::shared_ptr<ScanTask> ScannerContext::try_get_next_scan_task(
+        const std::unique_lock<std::mutex>& transfer_lock) {
+    if (_enable_adaptive_scanners) {
+        // Refresh expected_scanners and feed current block estimates back to 
the memory limiter,
+        // exactly as _get_margin() does on the TaskExecutor path. 
can_admit_scan_task() only reads
+        // the cached value, so ThreadPool admission would otherwise never 
apply adaptive limits.
+        static_cast<void>(_available_pickup_scanner_count());
+    }
+    if (!can_admit_scan_task(transfer_lock)) {
+        VLOG_DEBUG << fmt::format(
+                "[{}|{}] refuse admission, pending: {}, completed: {}, 
in-flight: {}, done: {}",
+                print_id(_query_id), ctx_id, _pending_tasks.size(), 
_completed_tasks.size(),
+                _in_flight_tasks_num, done());
+        return nullptr;
+    }
+
+    // Pop and mark in-flight while holding the same lock used by completion 
and consumption.
+    // Thus concurrent Context workers cannot admit the same task or both pass 
the limit check.
+    auto scan_task = _pending_tasks.top();
+    _pending_tasks.pop();
+    // ThreadPool admission bypasses ScannerScheduler::submit(); restart the 
per-scanner wait
+    // timer here so it measures admission-to-execution instead of everything 
since the previous
+    // attempt paused, which would include time the completed block waited for 
the operator.
+    if (auto scanner_delegate = scan_task->scanner.lock()) {
+        scanner_delegate->_scanner->start_wait_worker_timer();
+    }
+    scan_task->set_state(ScanTask::State::IN_FLIGHT);
+    ++_in_flight_tasks_num;
+    VLOG_DEBUG << fmt::format("[{}|{}] admit scanner, pending: {}, completed: 
{}, in-flight: {}",
+                              print_id(_query_id), ctx_id, 
_pending_tasks.size(),
+                              _completed_tasks.size(), _in_flight_tasks_num);
+    return scan_task;
+}
+
 void ScannerContext::update_peak_running_scanner(int num) {
 #ifndef BE_TEST
     _local_state->_peak_running_scanner->add(num);
diff --git a/be/src/exec/scan/scanner_context.h 
b/be/src/exec/scan/scanner_context.h
index 72a557c7461..aaeb36927d9 100644
--- a/be/src/exec/scan/scanner_context.h
+++ b/be/src/exec/scan/scanner_context.h
@@ -129,7 +129,11 @@ public:
     void set_state(State state) {
         switch (state) {
         case State::PENDING:
-            DCHECK(_state == State::PENDING || _state == State::IN_FLIGHT) << 
(int)_state;
+            // A task returns to PENDING after the operator consumes its 
non-EOS cached block.
+            // For example, one scanner may produce several blocks, so 
COMPLETED is not terminal.
+            DCHECK(_state == State::PENDING || _state == State::IN_FLIGHT ||
+                   _state == State::COMPLETED)
+                    << (int)_state;
             DCHECK(cached_block == nullptr);
             break;
         case State::IN_FLIGHT:
@@ -207,8 +211,9 @@ public:
     // set the `eos` to `ScanTask::eos` if there is no more data in current 
scanner
     Status submit_scan_task(std::shared_ptr<ScanTask> scan_task, 
std::unique_lock<std::mutex>&);
 
-    // Push back a scan task.
-    void push_back_scan_task(std::shared_ptr<ScanTask> scan_task);
+    // Publish a task whose current scan attempt has completed. The operator 
consumes its cached
+    // block and returns a non-EOS task to PENDING for its next scan attempt.
+    void push_completed_scan_task(std::shared_ptr<ScanTask> scan_task);
 
     // Return true if this ScannerContext need no more process
     bool done() const { return _is_finished || _should_stop; }
@@ -253,6 +258,39 @@ public:
                               std::unique_lock<std::mutex>& transfer_lock,
                               std::unique_lock<std::shared_mutex>& 
scheduler_lock);
 
+    // Context scheduling and operator consumption share this lock so 
queue-state changes and task
+    // admission form one atomic decision. For example, two worker callbacks 
cannot both admit the
+    // last available concurrency slot.
+    std::mutex& transfer_lock() { return _transfer_lock; }
+
+    // One Context submission represents many pending scanners in the 
ThreadPool scheduler.
+    // Keeping this separate from scanner execution prevents duplicate 
runnables from accumulating.
+    bool is_context_queued(const std::unique_lock<std::mutex>& transfer_lock) 
const;
+    // Transition the Context runnable's queue state. The caller must hold 
_transfer_lock.
+    void set_context_queued(bool queued, const std::unique_lock<std::mutex>& 
transfer_lock);
+
+    // Publish a scheduler failure and make the Context terminal. The caller 
must hold
+    // _transfer_lock so a retained ThreadPool callback cannot admit another 
scanner concurrently.
+    void set_context_failure(const Status& failure,
+                             const std::unique_lock<std::mutex>& 
transfer_lock);
+
+    // Return a scanner to the admission queue after its block is consumed. It 
may not own a cached
+    // block and may not be EOS: EOS scanners are terminal and must not run 
again.
+    void push_pending_scan_task(std::shared_ptr<ScanTask> scan_task,
+                                const std::unique_lock<std::mutex>& 
transfer_lock);
+
+    // Return whether a Context worker can currently admit one pending 
scanner. This check has no
+    // side effects, so the scheduler can avoid submitting a runnable that 
would immediately exit.
+    // It always admits one scanner when nothing is progressing so the 
operator can be woken, and it
+    // holds the Context at max(1, _min_scan_concurrency) while the scheduler 
pool has no slack,
+    // like _get_margin() on the TaskExecutor path. The caller must hold 
_transfer_lock.
+    bool can_admit_scan_task(const std::unique_lock<std::mutex>& 
transfer_lock) const;
+
+    // Atomically check whether this context can start another scan task, move 
one task from
+    // pending to in-flight, and return it. The caller must hold 
_transfer_lock.
+    std::shared_ptr<ScanTask> try_get_next_scan_task(
+            const std::unique_lock<std::mutex>& transfer_lock);
+
 protected:
     /// Four criteria to determine whether to increase the parallelism of the 
scanners
     /// 1. It ran for at least `SCALE_UP_DURATION` ms after last scale up
@@ -293,26 +331,35 @@ protected:
     //   current_concurrency = _completed_tasks.size() + _in_flight_tasks_num
     //
     // Lifecycle of a ScanTask:
-    //   _pending_tasks  --(submit_scan_task)--> [thread pool]  
--(push_back_scan_task)-->
-    //   _completed_tasks  --(get_block_from_queue)--> operator
+    //   _pending_tasks  --(submit_scan_task on the TaskExecutor path,
+    //                      try_get_next_scan_task on the ThreadPool path)--> 
[thread pool]
+    //   --(push_completed_scan_task)--> _completed_tasks  
--(get_block_from_queue)--> operator
     //   After consumption: non-EOS task goes back to _pending_tasks; EOS 
increments
     //   _num_finished_scanners.
 
     // Completed scan tasks whose cached_block is ready for the operator to 
consume.
-    // Protected by _transfer_lock.  Written by push_back_scan_task() (scanner 
thread),
+    // Protected by _transfer_lock.  Written by push_completed_scan_task() 
(scanner thread),
     // read/popped by get_block_from_queue() (operator thread).
     std::list<std::shared_ptr<ScanTask>> _completed_tasks;
 
-    // Scanners waiting to be submitted to the scheduler thread pool.  Stored 
as a stack
-    // (LIFO) so that recently-used scanners are re-scheduled first, which is 
more likely
-    // to be cache-friendly.  Protected by _transfer_lock.  Populated in the 
constructor
-    // and by schedule_scan_task() when the concurrency limit is reached; 
drained by
-    // _pull_next_scan_task() during scheduling.
+    // Scanners waiting to be admitted for execution. Stored as a stack (LIFO) 
so that
+    // recently-used scanners are re-scheduled first, which is more likely to 
be cache-friendly.
+    // Protected by _transfer_lock. Populated in the constructor and when an 
operator returns a
+    // non-EOS task; drained by try_get_next_scan_task() or the TaskExecutor 
scheduler.
     std::stack<std::shared_ptr<ScanTask>> _pending_tasks;
 
+    // True from the start of one Context submission until its runnable 
starts. The marker may
+    // remain true when no runnable was retained: a failed submission makes 
the Context terminal,
+    // and a submission that threw inside _run_context() publishes the error 
through the task that
+    // was already admitted. In both cases the operator observes 
_process_status, so no further
+    // submission is attempted. It does not describe scanners executing on 
workers. Protected by
+    // _transfer_lock.
+    bool _is_context_queued = false;
+
     // Number of scan tasks currently submitted to the scanner scheduler 
thread pool
-    // (i.e. in-flight).  Incremented by submit_scan_task() before submission 
and
-    // decremented by push_back_scan_task() when the thread pool returns the 
task.
+    // (i.e. in-flight). Incremented before a task is submitted or directly 
admitted for
+    // thread-pool execution, and decremented by push_completed_scan_task() 
when the worker
+    // returns it.
     // Declared atomic so it can be read without _transfer_lock in 
non-critical paths,
     // but must be read under _transfer_lock whenever combined with 
_completed_tasks.size()
     // to form a consistent concurrency snapshot.
diff --git a/be/src/exec/scan/scanner_scheduler.cpp 
b/be/src/exec/scan/scanner_scheduler.cpp
index bc53d83e850..c534ab64672 100644
--- a/be/src/exec/scan/scanner_scheduler.cpp
+++ b/be/src/exec/scan/scanner_scheduler.cpp
@@ -75,17 +75,7 @@ Status 
ScannerScheduler::submit(std::shared_ptr<ScannerContext> ctx,
     TabletStorageType type = scanner_delegate->_scanner->get_storage_type();
     auto sumbit_task = [&]() {
         auto work_func = [scanner_ref = scan_task, ctx]() {
-            auto status = [&] {
-                RETURN_IF_CATCH_EXCEPTION(_scanner_scan(ctx, scanner_ref));
-                return Status::OK();
-            }();
-
-            if (!status.ok()) {
-                scanner_ref->set_status(status);
-                ctx->push_back_scan_task(scanner_ref);
-                return true;
-            }
-            return scanner_ref->is_eos();
+            return execute_scan_task(ctx, scanner_ref);
         };
         SimplifiedScanTask simple_scan_task = {work_func, ctx, scan_task};
         return this->submit_scan_task(simple_scan_task);
@@ -104,6 +94,22 @@ Status 
ScannerScheduler::submit(std::shared_ptr<ScannerContext> ctx,
     return Status::OK();
 }
 
+bool ScannerScheduler::execute_scan_task(const 
std::shared_ptr<ScannerContext>& ctx,
+                                         const std::shared_ptr<ScanTask>& 
scan_task) {
+    // Both schedulers admit tasks differently, but exceptions must always 
become a completed task
+    // so the operator observes the error and releases the task's in-flight 
concurrency slot.
+    auto status = [&] {
+        RETURN_IF_CATCH_EXCEPTION(_scanner_scan(ctx, scan_task));
+        return Status::OK();
+    }();
+    if (!status.ok()) {
+        scan_task->set_status(status);
+        ctx->push_completed_scan_task(scan_task);
+        return true;
+    }
+    return scan_task->is_eos();
+}
+
 void handle_reserve_memory_failure(RuntimeState* state, 
std::shared_ptr<ScannerContext> ctx,
                                    const Status& st, size_t reserve_size) {
     ctx->clear_free_blocks();
@@ -324,7 +330,7 @@ void 
ScannerScheduler::_scanner_scan(std::shared_ptr<ScannerContext> ctx,
             "{}, eos: {}, status: {}",
             ctx->ctx_id, ctx->num_scheduled_scanners(), eos, 
status.to_string());
 
-    ctx->push_back_scan_task(scan_task);
+    ctx->push_completed_scan_task(scan_task);
 }
 // 
NOLINTEND(readability-function-cognitive-complexity,readability-function-size)
 
diff --git a/be/src/exec/scan/scanner_scheduler.h 
b/be/src/exec/scan/scanner_scheduler.h
index fa5387d2736..273954274d4 100644
--- a/be/src/exec/scan/scanner_scheduler.h
+++ b/be/src/exec/scan/scanner_scheduler.h
@@ -138,7 +138,11 @@ public:
 protected:
     int _min_active_scan_threads;
 
-private:
+    // Execute one admitted task for both scheduler implementations. The 
return value is consumed
+    // by TaskExecutor to distinguish terminal EOS/error tasks from scanners 
that remain runnable.
+    static bool execute_scan_task(const std::shared_ptr<ScannerContext>& ctx,
+                                  const std::shared_ptr<ScanTask>& scan_task);
+
     static void _scanner_scan(std::shared_ptr<ScannerContext> ctx,
                               std::shared_ptr<ScanTask> scan_task);
 
@@ -240,12 +244,13 @@ public:
                               std::unique_lock<std::mutex>& transfer_lock) 
override;
 
 private:
+    void _run_context(std::shared_ptr<ScannerContext> scanner_ctx);
+
     std::unique_ptr<ThreadPool> _scan_thread_pool;
     std::atomic<bool> _is_stop;
     std::weak_ptr<CgroupCpuCtl> _cgroup_cpu_ctl;
     std::string _sched_name;
     std::string _workload_group;
-    std::shared_mutex _lock;
 };
 
 class TaskExecutorSimplifiedScanScheduler final : public ScannerScheduler {
diff --git a/be/src/exec/scan/simplified_scan_scheduler.cpp 
b/be/src/exec/scan/simplified_scan_scheduler.cpp
index 275461ff1dd..e3ef29d6f58 100644
--- a/be/src/exec/scan/simplified_scan_scheduler.cpp
+++ b/be/src/exec/scan/simplified_scan_scheduler.cpp
@@ -17,8 +17,12 @@
 
 #include <memory>
 
+#include "common/exception.h"
+#include "common/logging.h"
 #include "exec/scan/scanner_context.h"
 #include "exec/scan/scanner_scheduler.h"
+#include "runtime/thread_context.h"
+#include "util/debug_points.h"
 
 namespace doris {
 class ScannerDelegate;
@@ -34,7 +38,104 @@ Status 
TaskExecutorSimplifiedScanScheduler::schedule_scan_task(
 Status ThreadPoolSimplifiedScanScheduler::schedule_scan_task(
         std::shared_ptr<ScannerContext> scanner_ctx, std::shared_ptr<ScanTask> 
current_scan_task,
         std::unique_lock<std::mutex>& transfer_lock) {
-    std::unique_lock<std::shared_mutex> wl(_lock);
-    return scanner_ctx->schedule_scan_task(current_scan_task, transfer_lock, 
wl);
+    // Unlike TaskExecutor, ThreadPool queues a Context runnable. It later 
admits one pending task
+    // under transfer_lock. This bounds queue entries to one per Context even 
when many scanners
+    // become runnable together.
+    DORIS_CHECK(transfer_lock.owns_lock());
+    if (current_scan_task != nullptr) {
+        // The operator has consumed a non-EOS result, making this scanner 
eligible for another
+        // scan attempt. Queue the scanner first; the Context runnable chooses 
it later.
+        scanner_ctx->push_pending_scan_task(std::move(current_scan_task), 
transfer_lock);
+    }
+    if (scanner_ctx->is_context_queued(transfer_lock)) {
+        // A queued runnable will see all pending scanners added before it 
obtains transfer_lock.
+        // Submitting another runnable would only duplicate work.
+        return Status::OK();
+    }
+    if (!scanner_ctx->can_admit_scan_task(transfer_lock)) {
+        // No runnable is needed when the Context has no pending scanner or 
its concurrency slots
+        // are occupied. A completion only wakes the operator; the operator's 
next consumption in
+        // get_block_from_queue(), or the successor submit in _run_context(), 
retries scheduling.
+        return Status::OK();
+    }
+
+    if (_is_stop) {
+        Status failure = Status::InternalError<false>("scanner pool {} is 
shutdown.", _sched_name);
+        scanner_ctx->set_context_failure(failure, transfer_lock);
+        return failure;
+    }
+
+    // ThreadPool::submit_func() may return an error after retaining the 
runnable. Set the marker
+    // before submission so either outcome is safe: a retained callback clears 
it, while a truly
+    // rejected callback leaves a terminal Context that no longer needs 
rescheduling.
+    scanner_ctx->set_context_queued(true, transfer_lock);
+    Status status =
+            _scan_thread_pool->submit_func([this, scanner_ctx] { 
_run_context(scanner_ctx); });
+    if (status.ok()) {
+        VLOG_DEBUG << "submit context runnable to scanner pool " << 
_sched_name << ", "
+                   << scanner_ctx->debug_string();
+        return Status::OK();
+    }
+    Status failure =
+            Status::TooManyTasks("Failed to submit scanner context {} to 
scanner pool, reason: {}",
+                                 scanner_ctx->ctx_id, status.msg());
+    scanner_ctx->set_context_failure(failure, transfer_lock);
+    return failure;
+}
+
+void 
ThreadPoolSimplifiedScanScheduler::_run_context(std::shared_ptr<ScannerContext> 
scanner_ctx) {
+    std::shared_ptr<ScanTask> scan_task;
+    Status admission_status = [&]() -> Status {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_ctx->transfer_lock());
+        scanner_ctx->set_context_queued(false, transfer_lock);
+
+        auto task_execution_lock = scanner_ctx->task_exec_ctx();
+        if (task_execution_lock == nullptr) {
+            return Status::OK();
+        }
+#ifndef BE_TEST
+        // Attach before admission: allocations below (for example the 
resubmitted
+        // FunctionRunnable) must charge the query rather than the orphan 
tracker. Scoped to this
+        // lambda so it detaches before execute_scan_task(), whose 
_scanner_scan() attaches again.
+        SCOPED_ATTACH_TASK(scanner_ctx->state());
+#endif
+        Status status = [&]() -> Status {
+            RETURN_IF_CATCH_EXCEPTION({
+                // Admission checks completed results, active tasks, and 
adaptive limits while
+                // holding transfer_lock.
+                scan_task = scanner_ctx->try_get_next_scan_task(transfer_lock);
+                if (scan_task != nullptr) {
+                    
DBUG_EXECUTE_IF("ThreadPoolSimplifiedScanScheduler._run_context.inject_failure",
+                                    {
+                                        throw 
Exception(ErrorCode::INTERNAL_ERROR,
+                                                        "injected admission 
failure");
+                                    });
+                    // Queue the next Context runnable before executing this 
task. Holding
+                    // transfer_lock keeps the admission decision atomic.
+                    RETURN_IF_ERROR(schedule_scan_task(scanner_ctx, nullptr, 
transfer_lock));
+                }
+            });
+            return Status::OK();
+        }();
+        if (!status.ok() && scan_task == nullptr) {
+            scanner_ctx->set_context_failure(status, transfer_lock);
+        }
+        return status;
+    }();
+    if (!admission_status.ok()) [[unlikely]] {
+        if (scan_task != nullptr) {
+            // The scanner was admitted before the failure. Publish it to 
release the in-flight
+            // slot and make the error observable by the operator.
+            scan_task->set_status(admission_status);
+            scanner_ctx->push_completed_scan_task(scan_task);
+        }
+        return;
+    }
+    if (scan_task == nullptr) {
+        return;
+    }
+    // The scan runs without transfer_lock so the operator and other Context 
workers can continue
+    // consuming results and admitting work. Completion reacquires the lock 
before publishing.
+    execute_scan_task(scanner_ctx, scan_task);
 }
 } // namespace doris
diff --git a/be/test/exec/scan/scanner_context_test.cpp 
b/be/test/exec/scan/scanner_context_test.cpp
index ea2382e3c4e..bff24bf4fbc 100644
--- a/be/test/exec/scan/scanner_context_test.cpp
+++ b/be/test/exec/scan/scanner_context_test.cpp
@@ -23,12 +23,16 @@
 #include <gen_cpp/Types_types.h>
 #include <gtest/gtest.h>
 
+#include <atomic>
+#include <chrono>
 #include <list>
 #include <memory>
 #include <mutex>
 #include <shared_mutex>
+#include <thread>
 #include <tuple>
 
+#include "common/config.h"
 #include "common/object_pool.h"
 #include "core/block/block.h"
 #include "exec/operator/olap_scan_operator.h"
@@ -36,16 +40,71 @@
 #include "exec/scan/mock_simplified_scan_scheduler.h"
 #include "exec/scan/olap_scanner.h"
 #include "exec/scan/scan_node.h"
+#include "exec/scan/scanner.h"
 #include "exec/scan/scanner_scheduler.h"
 #include "runtime/descriptors.h"
 #include "runtime/query_context.h"
+#include "runtime/task_execution_context.h"
 #include "storage/options.h"
 #include "storage/storage_engine.h"
 #include "storage/tablet/tablet.h"
 #include "storage/tablet/tablet_meta.h"
 #include "testutil/mock/mock_runtime_state.h"
+#include "util/countdown_latch.h"
+#include "util/debug_points.h"
+#include "util/defer_op.h"
 
 namespace doris {
+// A scanner that produces `blocks_per_scanner` one-row blocks and then 
reports EOS, without any
+// tablet or file behind it. It lets the ThreadPool scheduler chain (admit -> 
execute -> publish ->
+// consume -> re-admit) run end to end in a unit test. `overlap` is counted 
down the first time two
+// attempts run concurrently; the first attempt waits for it so the peak 
concurrency observed by the
+// test does not depend on timing.
+class ChainMockScanner : public Scanner {
+public:
+    ChainMockScanner(RuntimeState* state, ScanLocalStateBase* local_state, 
RuntimeProfile* profile,
+                     int blocks_per_scanner, std::atomic<int>* running,
+                     std::atomic<int>* peak_running, CountDownLatch* overlap)
+            : Scanner(state, local_state, -1, profile),
+              _blocks_left(blocks_per_scanner),
+              _running(running),
+              _peak_running(peak_running),
+              _overlap(overlap) {}
+
+protected:
+    Status _get_block_impl(RuntimeState* /*state*/, Block* block, bool* eof) 
override {
+        const int running = ++*_running;
+        Defer done([&] { --*_running; });
+        int peak = _peak_running->load();
+        while (running > peak && !_peak_running->compare_exchange_weak(peak, 
running)) {
+        }
+        if (running >= 2) {
+            _overlap->count_down();
+        } else {
+            // Bounded so a scheduler that never admits a second scanner fails 
the test instead of
+            // hanging it.
+            static_cast<void>(_overlap->wait_for(std::chrono::seconds(5)));
+        }
+        if (_blocks_left == 0) {
+            *eof = true;
+            return Status::OK();
+        }
+        --_blocks_left;
+        block->get_by_position(0).column->assert_mutable()->insert_default();
+        *eof = false;
+        return Status::OK();
+    }
+
+    // The local state in these tests has no profile counters.
+    void _collect_profile_before_close() override {}
+
+private:
+    int _blocks_left;
+    std::atomic<int>* _running;
+    std::atomic<int>* _peak_running;
+    CountDownLatch* _overlap;
+};
+
 class ScannerContextTest : public testing::Test {
 public:
     void SetUp() override {
@@ -169,7 +228,10 @@ TEST_F(ScannerContextTest, test_init) {
     state->set_query_options(query_options);
     std::unique_ptr<MockSimplifiedScanScheduler> scheduler =
             std::make_unique<MockSimplifiedScanScheduler>(cgroup_cpu_ctl);
+    // init() is invoked twice below, and each invocation performs one initial 
scheduling attempt.
+    // Keep this expectation explicit so changing bootstrap scheduling updates 
this test too.
     EXPECT_CALL(*scheduler, schedule_scan_task(testing::_, testing::_, 
testing::_))
+            .Times(2)
             .WillRepeatedly(testing::Return(Status::OK()));
     scanner_context->_scanner_scheduler = scheduler.get();
 
@@ -454,7 +516,7 @@ TEST_F(ScannerContextTest, test_max_column_reader_num) {
     ASSERT_EQ(scanner_context->_max_scan_concurrency, 1);
 }
 
-TEST_F(ScannerContextTest, test_push_back_scan_task) {
+TEST_F(ScannerContextTest, test_push_completed_scan_task) {
     const int parallel_tasks = 1;
     auto scan_operator = std::make_unique<OlapScanOperatorX>(obj_pool.get(), 
tnode, 0, *descs,
                                                              parallel_tasks, 
TQueryCacheParam {});
@@ -486,7 +548,7 @@ TEST_F(ScannerContextTest, test_push_back_scan_task) {
 
     for (int i = 0; i < 5; ++i) {
         auto scan_task = 
std::make_shared<ScanTask>(std::make_shared<ScannerDelegate>(scanner));
-        scanner_context->push_back_scan_task(scan_task);
+        scanner_context->push_completed_scan_task(scan_task);
         ASSERT_EQ(scanner_context->_in_flight_tasks_num, 10 - i);
     }
 }
@@ -664,6 +726,633 @@ TEST_F(ScannerContextTest, pull_next_scan_task) {
     EXPECT_NE(pull_scan_task, nullptr);
 }
 
+TEST_F(ScannerContextTest, thread_pool_admission_state) {
+    const int parallel_tasks = 1;
+    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 = -1;
+    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, -1,
+            scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+
+    // An idle pool: admission is bounded only by the per-Context limit.
+    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;
+
+    std::unique_lock<std::mutex> 
context_transfer_lock(scanner_context->transfer_lock());
+    scanner_context->_pending_tasks = std::stack<std::shared_ptr<ScanTask>>();
+    scanner_context->_completed_tasks.clear();
+    scanner_context->_in_flight_tasks_num = 0;
+    // Even if the effective limit is temporarily zero, one pending task must 
run so it can publish
+    // a block or EOS and prevent the Context from stalling.
+    scanner_context->_max_scan_concurrency = 0;
+
+    EXPECT_FALSE(scanner_context->can_admit_scan_task(context_transfer_lock));
+
+    auto completed_task = 
std::make_shared<ScanTask>(std::make_shared<ScannerDelegate>(scanner));
+    completed_task->set_state(ScanTask::State::IN_FLIGHT);
+    completed_task->cached_block = Block::create_unique();
+    completed_task->set_state(ScanTask::State::COMPLETED);
+    completed_task->cached_block.reset();
+    // A consumed non-EOS result must be eligible for another Context 
admission. This also covers
+    // the COMPLETED -> PENDING transition used by ThreadPool scheduling.
+    scanner_context->push_pending_scan_task(completed_task, 
context_transfer_lock);
+    EXPECT_TRUE(scanner_context->can_admit_scan_task(context_transfer_lock));
+
+    EXPECT_FALSE(scanner_context->is_context_queued(context_transfer_lock));
+    scanner_context->set_context_queued(true, context_transfer_lock);
+    EXPECT_TRUE(scanner_context->is_context_queued(context_transfer_lock));
+    scanner_context->set_context_queued(false, context_transfer_lock);
+
+    // The Context can admit exactly one scanner at its configured concurrency 
limit.
+    auto admitted_task = 
scanner_context->try_get_next_scan_task(context_transfer_lock);
+    EXPECT_EQ(admitted_task, completed_task);
+    EXPECT_EQ(admitted_task->_state, ScanTask::State::IN_FLIGHT);
+    EXPECT_EQ(scanner_context->_in_flight_tasks_num, 1);
+
+    auto blocked_task = 
std::make_shared<ScanTask>(std::make_shared<ScannerDelegate>(scanner));
+    scanner_context->push_pending_scan_task(blocked_task, 
context_transfer_lock);
+    EXPECT_FALSE(scanner_context->can_admit_scan_task(context_transfer_lock));
+    EXPECT_EQ(scanner_context->try_get_next_scan_task(context_transfer_lock), 
nullptr);
+}
+
+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,
+                                                             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 = -1;
+    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;
+    for (int i = 0; i < 5; ++i) {
+        scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+    }
+
+    TUniqueId query_id = state->get_query_ctx()->query_id();
+    const int64_t query_mem_limit = 1024LL * 1024 * 1024;
+    auto arbitrator = MemShareArbitrator::create_shared(query_id, 
query_mem_limit, 0.3);
+    auto limiter = MemLimiter::create_shared(query_id, parallel_tasks, false,
+                                             
static_cast<int64_t>(query_mem_limit * 0.3));
+    // 200MB budget with 100MB estimated blocks: max_count = 2, so instance 1 
gets exactly one
+    // adaptive slot. ins_idx = 1 keeps _available_pickup_scanner_count() away 
from the
+    // arbitrator-driven limit adjustment, which would overwrite this 
deterministic setup.
+    limiter->update_open_tasks_count(1);
+    limiter->update_mem_limit(200LL * 1024 * 1024);
+    limiter->reestimated_block_mem_bytes(100LL * 1024 * 1024);
+
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, -1,
+            scan_dependency, &shared_limit, arbitrator, limiter, 1, true, 
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;
+
+    std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+    ASSERT_TRUE(scanner_context->_enable_adaptive_scanners);
+    ASSERT_LT(1, scanner_context->_max_scan_concurrency);
+    EXPECT_EQ(scanner_context->_adaptive_processor->expected_scanners, 0);
+
+    // Admission refreshes the adaptive limit. Nothing is progressing yet, so 
the first scanner is
+    // admitted regardless, but expected_scanners must no longer stay at its 
initial zero.
+    auto first_task = scanner_context->try_get_next_scan_task(transfer_lock);
+    ASSERT_NE(first_task, nullptr);
+    EXPECT_EQ(scanner_context->_adaptive_processor->expected_scanners, 1);
+
+    // One task is in flight and the refreshed adaptive limit is one: 
admission must refuse the
+    // next scanner even though _max_scan_concurrency would still allow it.
+    EXPECT_FALSE(scanner_context->can_admit_scan_task(transfer_lock));
+    EXPECT_EQ(scanner_context->try_get_next_scan_task(transfer_lock), nullptr);
+}
+
+TEST_F(ScannerContextTest, 
thread_pool_admission_keeps_zero_adaptive_allocation) {
+    const int parallel_tasks = 2;
+    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 = -1;
+    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;
+    for (int i = 0; i < 5; ++i) {
+        scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+    }
+
+    TUniqueId query_id = state->get_query_ctx()->query_id();
+    const int64_t query_mem_limit = 1024LL * 1024 * 1024;
+    auto arbitrator = MemShareArbitrator::create_shared(query_id, 
query_mem_limit, 0.3);
+    auto limiter = MemLimiter::create_shared(query_id, parallel_tasks, false,
+                                             
static_cast<int64_t>(query_mem_limit * 0.3));
+    // 100MB budget with 100MB estimated blocks: max_count = 1 for two 
instances, so instance 1
+    // is legitimately allocated zero scanners by the node-wide budget.
+    limiter->update_open_tasks_count(1);
+    limiter->update_mem_limit(100LL * 1024 * 1024);
+    limiter->reestimated_block_mem_bytes(100LL * 1024 * 1024);
+
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, -1,
+            scan_dependency, &shared_limit, arbitrator, limiter, 1, true, 
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;
+
+    std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+    ASSERT_LT(1, scanner_context->_max_scan_concurrency);
+
+    // Nothing is progressing, so one scanner is admitted even though the 
refreshed allocation
+    // is zero.
+    auto first_task = scanner_context->try_get_next_scan_task(transfer_lock);
+    ASSERT_NE(first_task, nullptr);
+    EXPECT_EQ(scanner_context->_adaptive_processor->expected_scanners, 0);
+    EXPECT_EQ(scanner_context->_in_flight_tasks_num, 1);
+
+    // Zero is the real ceiling: with one task in flight nothing else may be 
admitted, matching
+    // the single progress task the TaskExecutor margin keeps. It must not 
fall back to
+    // _max_scan_concurrency.
+    EXPECT_FALSE(scanner_context->can_admit_scan_task(transfer_lock));
+    EXPECT_EQ(scanner_context->try_get_next_scan_task(transfer_lock), nullptr);
+}
+
+TEST_F(ScannerContextTest, 
thread_pool_admission_holds_minimum_when_pool_saturated) {
+    const int parallel_tasks = 4;
+    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 = -1;
+    scanner_params.key_ranges = std::vector<OlapScanRange*>();
+    std::shared_ptr<Scanner> scanner =
+            OlapScanner::create_shared(olap_scan_local_state.get(), 
std::move(scanner_params));
+
+    // A single-worker pool whose worker is parked: active + queued == 1, i.e. 
the pool has no
+    // slack once the scheduler-wide budget is 1.
+    ThreadPoolSimplifiedScanScheduler scheduler("saturated_pool_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(1, 1, 4, 1).ok());
+    CountDownLatch task_started(1);
+    CountDownLatch release_task(1);
+    Defer cleanup = [&] {
+        release_task.count_down();
+        scheduler.stop();
+    };
+    ASSERT_TRUE(scheduler
+                        .submit_scan_task(SimplifiedScanTask(
+                                [&] {
+                                    task_started.count_down();
+                                    release_task.wait();
+                                    return true;
+                                },
+                                nullptr, nullptr))
+                        .ok());
+    ASSERT_TRUE(task_started.wait_for(std::chrono::seconds(5)));
+    ASSERT_EQ(scheduler.get_active_threads(), 1);
+    ASSERT_EQ(scheduler.get_queue_size(), 0);
+
+    // Two Contexts share the saturated pool; each may have up to four 
scanners outstanding when
+    // the pool has slack.
+    std::vector<std::shared_ptr<ScannerContext>> contexts;
+    for (int i = 0; i < 2; ++i) {
+        std::list<std::shared_ptr<ScannerDelegate>> scanners;
+        for (int j = 0; j < 4; ++j) {
+            scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+        }
+        auto scanner_context = ScannerContext::create_shared(
+                state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, -1,
+                scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+        scanner_context->_scanner_scheduler = &scheduler;
+        scanner_context->_min_scan_concurrency = 1;
+        contexts.push_back(scanner_context);
+    }
+
+    for (const auto& scanner_context : contexts) {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        ASSERT_EQ(scanner_context->_max_scan_concurrency, parallel_tasks);
+
+        // Saturated: one outstanding scanner is the ceiling, as _get_margin() 
enforces on the
+        // TaskExecutor path.
+        scanner_context->_min_scan_concurrency_of_scan_scheduler = 1;
+        scanner_context->_in_flight_tasks_num = 0;
+        EXPECT_TRUE(scanner_context->can_admit_scan_task(transfer_lock));
+        scanner_context->_in_flight_tasks_num = 1;
+        EXPECT_FALSE(scanner_context->can_admit_scan_task(transfer_lock));
+
+        // A larger minimum raises the saturated ceiling accordingly.
+        scanner_context->_min_scan_concurrency = 2;
+        EXPECT_TRUE(scanner_context->can_admit_scan_task(transfer_lock));
+        scanner_context->_in_flight_tasks_num = 2;
+        EXPECT_FALSE(scanner_context->can_admit_scan_task(transfer_lock));
+
+        // With slack the Context may ramp to its maximum again.
+        scanner_context->_min_scan_concurrency_of_scan_scheduler = 20;
+        EXPECT_TRUE(scanner_context->can_admit_scan_task(transfer_lock));
+        scanner_context->_in_flight_tasks_num = parallel_tasks;
+        EXPECT_FALSE(scanner_context->can_admit_scan_task(transfer_lock));
+    }
+}
+
+TEST_F(ScannerContextTest, debug_string_reports_distinguishable_fields) {
+    const int parallel_tasks = 3;
+    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 = -1;
+    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, 7,
+            scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+
+    // Every value is distinct so a misplaced placeholder is visible in the 
output.
+    shared_limit.store(100);
+    scanner_context->_in_flight_tasks_num = 2;
+    scanner_context->_is_context_queued = true;
+    scanner_context->_num_finished_scanners = 5;
+
+    const std::string debug = scanner_context->debug_string();
+    EXPECT_NE(debug.find("limit: 7, remaining_limit: 100, 
_in_flight_tasks_num: 2, "
+                         "_is_context_queued: true, _num_finished_scanners: 5, 
"
+                         "_max_scan_concurrency: 3, expected_scanners: -1,"),
+              std::string::npos)
+            << debug;
+}
+
+TEST_F(ScannerContextTest, thread_pool_submit_failure_policy) {
+    const int parallel_tasks = 2;
+    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 = -1;
+    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;
+    for (int i = 0; i < 2; ++i) {
+        scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+    }
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, -1,
+            scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+
+    // One worker, zero queue capacity, worker occupied: every submit_func() 
is rejected.
+    ThreadPoolSimplifiedScanScheduler scheduler("submit_failure_policy_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(1, 1, 0, 1).ok());
+    CountDownLatch task_started(1);
+    CountDownLatch release_task(1);
+    Defer cleanup = [&] {
+        release_task.count_down();
+        scheduler.stop();
+    };
+    ASSERT_TRUE(scheduler
+                        .submit_scan_task(SimplifiedScanTask(
+                                [&] {
+                                    task_started.count_down();
+                                    release_task.wait();
+                                    return true;
+                                },
+                                nullptr, nullptr))
+                        .ok());
+    ASSERT_TRUE(task_started.wait_for(std::chrono::seconds(5)));
+    scanner_context->_scanner_scheduler = &scheduler;
+
+    std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+    ASSERT_FALSE(scanner_context->_pending_tasks.empty());
+
+    // Context submission is fail-fast regardless of other progress. Retrying 
here would couple
+    // the scanner scheduler to ThreadPool's internal rejection/retention 
behavior.
+    Status surfaced = scheduler.schedule_scan_task(scanner_context, nullptr, 
transfer_lock);
+    EXPECT_TRUE(surfaced.is<ErrorCode::TOO_MANY_TASKS>()) << 
surfaced.to_string();
+    EXPECT_TRUE(scanner_context->done());
+    EXPECT_FALSE(scanner_context->_process_status.ok());
+    EXPECT_TRUE(scan_dependency->ready());
+    // The marker is set before submit_func(). This rejected runnable was not 
retained, but the
+    // terminal Context no longer needs the marker cleared or another 
submission attempted.
+    EXPECT_TRUE(scanner_context->is_context_queued(transfer_lock));
+}
+
+TEST_F(ScannerContextTest, run_context_publishes_admission_failure) {
+    const bool old_enable_debug_points = config::enable_debug_points;
+    config::enable_debug_points = true;
+    
DebugPoints::instance()->add("ThreadPoolSimplifiedScanScheduler._run_context.inject_failure");
+    Defer cleanup_debug_point = [&] {
+        DebugPoints::instance()->remove(
+                
"ThreadPoolSimplifiedScanScheduler._run_context.inject_failure");
+        config::enable_debug_points = old_enable_debug_points;
+    };
+
+    const int parallel_tasks = 2;
+    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 = -1;
+    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;
+    for (int i = 0; i < 2; ++i) {
+        scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+    }
+    // The worker's task_exec_ctx() must resolve, otherwise _run_context() 
exits before admission.
+    // HasTaskExecutionCtx snapshots the weak_ptr at construction, so set it 
before create_shared.
+    auto task_execution_context = std::make_shared<TaskExecutionContext>();
+    state->set_task_execution_context(task_execution_context);
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, -1,
+            scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+
+    ThreadPoolSimplifiedScanScheduler scheduler("run_context_failure_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(1, 1, 1, 1).ok());
+    Defer cleanup = [&] { scheduler.stop(); };
+    scanner_context->_scanner_scheduler = &scheduler;
+
+    {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        ASSERT_TRUE(scheduler.schedule_scan_task(scanner_context, nullptr, 
transfer_lock).ok());
+        ASSERT_TRUE(scanner_context->is_context_queued(transfer_lock));
+    }
+
+    // The worker admits a scanner and hits the injected exception. It must 
publish the failure
+    // as a completed task instead of terminating the process or leaking the 
in-flight slot.
+    bool published = false;
+    for (int i = 0; i < 10000; ++i) {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        if (!scanner_context->_completed_tasks.empty()) {
+            published = true;
+            break;
+        }
+        transfer_lock.unlock();
+        std::this_thread::sleep_for(std::chrono::milliseconds(1));
+    }
+    ASSERT_TRUE(published);
+
+    std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+    ASSERT_EQ(scanner_context->_completed_tasks.size(), 1);
+    EXPECT_FALSE(scanner_context->_completed_tasks.front()->status_ok());
+    EXPECT_EQ(scanner_context->_in_flight_tasks_num, 0);
+    EXPECT_FALSE(scanner_context->is_context_queued(transfer_lock));
+}
+
+TEST_F(ScannerContextTest, thread_pool_context_chain_runs_all_scanners) {
+    const int parallel_tasks = 2;
+    const int scanner_count = 3;
+    const int blocks_per_scanner = 4;
+    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());
+    olap_scan_local_state->_parent = scan_operator.get();
+    olap_scan_local_state->_max_scan_concurrency = 
max_concurrency_counter.get();
+    olap_scan_local_state->_min_scan_concurrency = 
min_concurrency_counter.get();
+    scan_operator->_should_run_serial = false;
+    TQueryOptions query_options;
+    query_options.__set_max_column_reader_num(0);
+    state->set_query_options(query_options);
+
+    std::atomic<int> running {0};
+    std::atomic<int> peak_running {0};
+    CountDownLatch overlap(1);
+    std::list<std::shared_ptr<ScannerDelegate>> scanners;
+    for (int i = 0; i < scanner_count; ++i) {
+        std::shared_ptr<Scanner> scanner = std::make_shared<ChainMockScanner>(
+                state.get(), olap_scan_local_state.get(), profile.get(), 
blocks_per_scanner,
+                &running, &peak_running, &overlap);
+        scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+    }
+
+    // The worker's task_exec_ctx() must resolve, otherwise _run_context() 
exits before admission.
+    auto task_execution_context = std::make_shared<TaskExecutionContext>();
+    state->set_task_execution_context(task_execution_context);
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, -1,
+            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();
+
+    // Two workers so the successor runnable can overlap with the executing 
scanner.
+    ThreadPoolSimplifiedScanScheduler scheduler("context_chain_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(2, 2, 16, 1).ok());
+    Defer cleanup = [&] { scheduler.stop(); };
+    scanner_context->_scanner_scheduler = &scheduler;
+    // The two-thread pool never reaches this budget, so the Context may ramp 
to its maximum.
+    scanner_context->_min_scan_concurrency_of_scan_scheduler = 20;
+    ASSERT_EQ(scanner_context->_max_scan_concurrency, parallel_tasks);
+
+    // init() performs the bootstrap submission of the first Context runnable.
+    ASSERT_TRUE(scanner_context->init().ok());
+
+    int64_t rows = 0;
+    bool eos = false;
+    const auto deadline = std::chrono::steady_clock::now() + 
std::chrono::seconds(20);
+    while (!eos) {
+        ASSERT_LT(std::chrono::steady_clock::now(), deadline) << 
scanner_context->debug_string();
+        // One Context submission represents all pending scanners, so the pool 
never holds more
+        // than one runnable for this Context.
+        EXPECT_LE(scheduler.get_queue_size(), 1);
+        Block block;
+        Status st = scanner_context->get_block_from_queue(state.get(), &block, 
&eos, 0);
+        ASSERT_TRUE(st.ok()) << st.to_string();
+        rows += block.rows();
+        if (!eos && block.rows() == 0) {
+            std::this_thread::sleep_for(std::chrono::milliseconds(1));
+        }
+    }
+
+    // Every consumed non-EOS scanner was re-admitted until it reported EOS.
+    EXPECT_EQ(rows, scanner_count * blocks_per_scanner);
+    std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+    EXPECT_EQ(scanner_context->_num_finished_scanners, scanner_count);
+    EXPECT_EQ(scanner_context->_in_flight_tasks_num, 0);
+    EXPECT_TRUE(scanner_context->_pending_tasks.empty());
+    EXPECT_FALSE(scanner_context->is_context_queued(transfer_lock));
+    EXPECT_TRUE(scanner_context->_process_status.ok());
+    // The successor runnable ramped concurrency to the per-Context limit, and 
never beyond it.
+    EXPECT_EQ(peak_running.load(), parallel_tasks);
+}
+
+TEST_F(ScannerContextTest, thread_pool_context_runnable_is_deduplicated) {
+    const int parallel_tasks = 2;
+    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 = -1;
+    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;
+    for (int i = 0; i < 3; ++i) {
+        scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+    }
+    auto scanner_context = ScannerContext::create_shared(
+            state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, -1,
+            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();
+
+    // One worker, parked, with queue capacity: a submitted Context runnable 
stays observable in
+    // the queue instead of being executed or rejected.
+    ThreadPoolSimplifiedScanScheduler scheduler("context_dedup_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(1, 1, 4, 1).ok());
+    CountDownLatch task_started(1);
+    CountDownLatch release_task(1);
+    Defer cleanup = [&] {
+        release_task.count_down();
+        scheduler.stop();
+    };
+    ASSERT_TRUE(scheduler
+                        .submit_scan_task(SimplifiedScanTask(
+                                [&] {
+                                    task_started.count_down();
+                                    release_task.wait();
+                                    return true;
+                                },
+                                nullptr, nullptr))
+                        .ok());
+    ASSERT_TRUE(task_started.wait_for(std::chrono::seconds(5)));
+    scanner_context->_scanner_scheduler = &scheduler;
+    scanner_context->_min_scan_concurrency_of_scan_scheduler = 20;
+
+    auto completed_task = std::make_shared<ScanTask>(scanners.front());
+    {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        ASSERT_TRUE(scheduler.schedule_scan_task(scanner_context, nullptr, 
transfer_lock).ok());
+        EXPECT_TRUE(scanner_context->is_context_queued(transfer_lock));
+        EXPECT_EQ(scheduler.get_queue_size(), 1);
+
+        // A second scheduling attempt while a runnable is queued must not add 
another runnable.
+        ASSERT_TRUE(scheduler.schedule_scan_task(scanner_context, nullptr, 
transfer_lock).ok());
+        EXPECT_TRUE(scanner_context->is_context_queued(transfer_lock));
+        EXPECT_EQ(scheduler.get_queue_size(), 1);
+
+        // Publish a completed non-EOS result so the operator can consume it 
below.
+        completed_task->set_state(ScanTask::State::IN_FLIGHT);
+        completed_task->cached_block = Block::create_unique();
+        completed_task->set_state(ScanTask::State::COMPLETED);
+        scanner_context->_completed_tasks.push_back(completed_task);
+        scanner_context->_in_flight_tasks_num = 1;
+    }
+
+    // Consuming a non-EOS result returns the scanner to the admission queue. 
The queued runnable
+    // will see it, so no additional runnable is submitted.
+    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);
+    {
+        std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+        EXPECT_EQ(completed_task->_state, ScanTask::State::PENDING);
+        EXPECT_EQ(completed_task->cached_block, nullptr);
+        ASSERT_FALSE(scanner_context->_pending_tasks.empty());
+        EXPECT_EQ(scanner_context->_pending_tasks.top(), completed_task);
+        EXPECT_TRUE(scanner_context->is_context_queued(transfer_lock));
+        EXPECT_EQ(scheduler.get_queue_size(), 1);
+        // Cancel the query before the parked worker runs the queued runnable, 
so it exits without
+        // touching the OlapScanner that has no tablet behind it.
+        scanner_context->_should_stop = true;
+    }
+}
+
+TEST_F(ScannerContextTest, thread_pool_stopped_scheduler_fails_context) {
+    const int parallel_tasks = 2;
+    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 = -1;
+    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, -1,
+            scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+
+    ThreadPoolSimplifiedScanScheduler scheduler("stopped_scheduler_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(1, 1, 1, 1).ok());
+    scheduler.stop();
+    scanner_context->_scanner_scheduler = &scheduler;
+
+    std::unique_lock<std::mutex> 
transfer_lock(scanner_context->transfer_lock());
+    ASSERT_FALSE(scanner_context->_pending_tasks.empty());
+    Status surfaced = scheduler.schedule_scan_task(scanner_context, nullptr, 
transfer_lock);
+    EXPECT_TRUE(surfaced.is<ErrorCode::INTERNAL_ERROR>()) << 
surfaced.to_string();
+    // The Context is terminal and the operator is woken to observe the 
failure. No runnable was
+    // submitted, so the marker stays clear.
+    EXPECT_TRUE(scanner_context->done());
+    EXPECT_FALSE(scanner_context->_process_status.ok());
+    EXPECT_TRUE(scan_dependency->ready());
+    EXPECT_FALSE(scanner_context->is_context_queued(transfer_lock));
+}
+
 TEST_F(ScannerContextTest, schedule_scan_task) {
     const int parallel_tasks = 4;
     auto scan_operator = std::make_unique<OlapScanOperatorX>(obj_pool.get(), 
tnode, 0, *descs,
@@ -950,6 +1639,7 @@ TEST_F(ScannerContextTest, get_block_from_queue) {
     std::shared_ptr<ScannerContext> 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);
+    shared_limit.store(limit);
     scanner_context->_newly_create_free_blocks_num = 
newly_create_free_blocks_num.get();
     scanner_context->_scanner_memory_used_counter = 
scanner_memory_used_counter.get();
     scanner_context->_max_bytes_in_queue = 200;
@@ -999,6 +1689,81 @@ TEST_F(ScannerContextTest, get_block_from_queue) {
     EXPECT_EQ(scanner_context->_num_finished_scanners, 1);
 }
 
+TEST_F(ScannerContextTest, terminal_eos_skips_context_submission) {
+    ThreadPoolSimplifiedScanScheduler scheduler("terminal_eos_test", 
cgroup_cpu_ctl);
+    ASSERT_TRUE(scheduler.start(1, 1, 0, 1).ok());
+    CountDownLatch task_started(1);
+    CountDownLatch release_task(1);
+    Defer cleanup = [&] {
+        release_task.count_down();
+        scheduler.stop();
+    };
+    ASSERT_TRUE(scheduler
+                        .submit_scan_task(SimplifiedScanTask(
+                                [&] {
+                                    task_started.count_down();
+                                    release_task.wait();
+                                    return true;
+                                },
+                                nullptr, nullptr))
+                        .ok());
+    ASSERT_TRUE(task_started.wait_for(std::chrono::seconds(5)));
+    ASSERT_EQ(scheduler.get_active_threads(), 1);
+
+    auto verify_terminal_context = [&](int scanner_count, int64_t 
remaining_limit) {
+        const int parallel_tasks = 1;
+        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 = 100;
+        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;
+        for (int i = 0; i < scanner_count; ++i) {
+            scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
+        }
+        auto scanner_context = ScannerContext::create_shared(
+                state.get(), olap_scan_local_state.get(), output_tuple_desc, 
false, scanners, 100,
+                scan_dependency, &shared_limit, nullptr, nullptr, 0, false, 
parallel_tasks);
+        scanner_context->_scanner_scheduler = &scheduler;
+
+        scanner_context->_pending_tasks = 
std::stack<std::shared_ptr<ScanTask>>();
+        auto scanner_iter = scanners.begin();
+        auto eos_task = std::make_shared<ScanTask>(*scanner_iter++);
+        eos_task->set_state(ScanTask::State::IN_FLIGHT);
+        eos_task->set_state(ScanTask::State::EOS);
+        scanner_context->_completed_tasks.push_back(eos_task);
+        while (scanner_iter != scanners.end()) {
+            
scanner_context->_pending_tasks.push(std::make_shared<ScanTask>(*scanner_iter++));
+        }
+        scanner_context->_in_flight_tasks_num = 0;
+        shared_limit.store(remaining_limit);
+
+        MockRuntimeStateLocal mock_runtime_state;
+        EXPECT_CALL(mock_runtime_state, 
is_cancelled()).WillRepeatedly(testing::Return(false));
+        Block block;
+        bool eos = false;
+        Status status = 
scanner_context->get_block_from_queue(&mock_runtime_state, &block, &eos, 0);
+
+        EXPECT_TRUE(status.ok()) << status.to_string();
+        EXPECT_TRUE(eos);
+        EXPECT_EQ(scheduler.get_queue_size(), 0);
+    };
+
+    // All scanners completed: no runnable is needed even if the pool cannot 
accept one.
+    verify_terminal_context(1, 100);
+    // Shared LIMIT completed the Context while another scanner is pending. 
Check terminal state
+    // before rescheduling so the full pool cannot turn successful EOS into 
TOO_MANY_TASKS.
+    verify_terminal_context(2, 0);
+}
+
 /**
     MemShareArbitrator Tests (5 tests)
   - scanner_mem_share_arbitrator_basic: Tests initialization, query_id, memory 
limits, and initial state


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

Reply via email to