Skip to content

Commit ed467c5

Browse files
morningmanclaude
andcommitted
[fix](be) Hold a zero adaptive scanner allocation on the TaskExecutor path
### What problem does this PR solve? Issue Number: None Related PR: #61271, #67070 Problem Summary: `MemLimiter::available_scanner_count()` divides a scan node's memory budget, counted in blocks, among its instances. When the budget holds fewer blocks than there are instances, the later instances get zero: they may keep one task running, so that the scan still makes progress, and no more. The thread-pool path holds them there (`can_admit_scan_task()`, since #67070). The TaskExecutor path, the default for internal and external tables, did not: `_pull_next_scan_task()` read `expected_scanners == 0` as `_max_scan_concurrency`, and `_get_margin()` always asks for at least one task, so while the scheduler had slack every block the operator consumed put its scanner straight back to work, and a scanner that finished was replaced by a pending one. While the adaptive concurrency stayed at its minimum of one, that made no difference. With the previous commit an instance runs up to its memory ceiling; when the ceiling then falls to zero (larger blocks, or another scan node of the query estimating larger ones), the instance kept every scanner it held, up to `_max_scan_concurrency` (16 for file scans), for the rest of the scan, while a ceiling of one drained to one. `_pull_next_scan_task()` now takes `expected_scanners` as it is: `_get_margin()` refreshes it just before, so zero is always an allocation, never a value not yet set. As in `can_admit_scan_task()`, a task is still admitted when nothing is occupied, so a zero allocation cannot stall the scan. ### Release note None ### Check List (For Author) - Test: Unit Test - `ScannerContextTest.task_executor_keeps_zero_adaptive_allocation` (new): on the TaskExecutor path, an instance running two scanners whose allocation falls to zero puts the scanner whose block was consumed back to pending while the other one is in flight, and still runs one scanner once nothing is occupied. Without the change to `_pull_next_scan_task()` it fails: the consumed scanner is resubmitted. All 44 `ScannerContextTest` pass (ASAN, macOS, clang 22). - Behavior changed: Yes. With `enable_adaptive_scan` on and the TaskExecutor scheduler, an instance whose memory allocation falls to zero drains to one running scanner instead of keeping the ones it had. - Does this need documentation: No Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 8784c4b commit ed467c5

2 files changed

Lines changed: 83 additions & 4 deletions

File tree

‎be/src/exec/scan/scanner_context.cpp‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -839,12 +839,15 @@ std::shared_ptr<ScanTask> ScannerContext::_pull_next_scan_task(
839839
std::shared_ptr<ScanTask> current_scan_task, int32_t current_concurrency) {
840840
int32_t effective_max_concurrency = _max_scan_concurrency;
841841
if (_enable_adaptive_scanners) {
842-
effective_max_concurrency = _adaptive_processor->expected_scanners > 0
843-
? _adaptive_processor->expected_scanners
844-
: _max_scan_concurrency;
842+
// _get_margin() has just refreshed expected_scanners, so zero is a real allocation here, as
843+
// in can_admit_scan_task(), not a missing one. Reading it as _max_scan_concurrency let an
844+
// instance whose allocation fell to zero keep every scanner it held.
845+
effective_max_concurrency = _adaptive_processor->expected_scanners;
845846
}
846847

847-
if (current_concurrency >= effective_max_concurrency) {
848+
// Keep one task progressing even when the adaptive limit is zero. Otherwise no worker can
849+
// publish a result and wake the operator to make another scheduling decision.
850+
if (current_concurrency > 0 && current_concurrency >= effective_max_concurrency) {
848851
VLOG_DEBUG << fmt::format(
849852
"ScannerContext {} current concurrency {} >= effective_max_concurrency {}, skip "
850853
"pull",

‎be/test/exec/scan/scanner_context_test.cpp‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,82 @@ TEST_F(ScannerContextTest, adaptive_margin_takes_memory_ceiling) {
952952
EXPECT_EQ(scanner_context->_pull_next_scan_task(nullptr, parallel_tasks), nullptr);
953953
}
954954

955+
TEST_F(ScannerContextTest, task_executor_keeps_zero_adaptive_allocation) {
956+
const int parallel_tasks = 2;
957+
auto scan_operator = std::make_unique<OlapScanOperatorX>(obj_pool.get(), tnode, 0, *descs,
958+
parallel_tasks, TQueryCacheParam {});
959+
auto olap_scan_local_state =
960+
OlapScanLocalState::create_unique(state.get(), scan_operator.get());
961+
962+
OlapScanner::Params scanner_params;
963+
scanner_params.state = state.get();
964+
scanner_params.profile = profile.get();
965+
scanner_params.limit = -1;
966+
scanner_params.key_ranges = std::vector<OlapScanRange*>();
967+
std::shared_ptr<Scanner> scanner =
968+
OlapScanner::create_shared(olap_scan_local_state.get(), std::move(scanner_params));
969+
970+
std::list<std::shared_ptr<ScannerDelegate>> scanners;
971+
for (int i = 0; i < 5; ++i) {
972+
scanners.push_back(std::make_shared<ScannerDelegate>(scanner));
973+
}
974+
975+
TUniqueId query_id = state->get_query_ctx()->query_id();
976+
const int64_t query_mem_limit = 1024LL * 1024 * 1024;
977+
auto arbitrator = MemShareArbitrator::create_shared(query_id, query_mem_limit, 0.3);
978+
auto limiter = MemLimiter::create_shared(query_id, parallel_tasks, false,
979+
static_cast<int64_t>(query_mem_limit * 0.3));
980+
// 1GB budget with 1MB estimated blocks: instance 1 may run both scanners this Context allows.
981+
// ins_idx = 1 keeps _available_pickup_scanner_count() away from the arbitrator-driven limit
982+
// adjustment, which would overwrite the budgets set below.
983+
limiter->update_open_tasks_count(1);
984+
limiter->update_mem_limit(1024LL * 1024 * 1024);
985+
limiter->reestimated_block_mem_bytes(1024LL * 1024);
986+
987+
auto scanner_context = ScannerContext::create_shared(
988+
state.get(), olap_scan_local_state.get(), output_tuple_desc, false, scanners, -1,
989+
scan_dependency, &shared_limit, arbitrator, limiter, 1, true, parallel_tasks);
990+
std::unique_ptr<MockSimplifiedScanScheduler> scheduler =
991+
std::make_unique<MockSimplifiedScanScheduler>(cgroup_cpu_ctl);
992+
EXPECT_CALL(*scheduler, get_active_threads()).WillRepeatedly(testing::Return(0));
993+
EXPECT_CALL(*scheduler, get_queue_size()).WillRepeatedly(testing::Return(0));
994+
scanner_context->_scanner_scheduler = scheduler.get();
995+
scanner_context->_min_scan_concurrency_of_scan_scheduler = 20;
996+
scanner_context->_min_scan_concurrency = 1;
997+
998+
std::mutex transfer_mutex;
999+
std::unique_lock<std::mutex> transfer_lock(transfer_mutex);
1000+
std::shared_mutex scheduler_mutex;
1001+
std::unique_lock<std::shared_mutex> scheduler_lock(scheduler_mutex);
1002+
1003+
// Memory allows both scanners, and the TaskExecutor path starts both.
1004+
ASSERT_TRUE(scanner_context->schedule_scan_task(nullptr, transfer_lock, scheduler_lock).ok());
1005+
EXPECT_EQ(scanner_context->_adaptive_processor->expected_scanners, parallel_tasks);
1006+
EXPECT_EQ(scanner_context->_in_flight_tasks_num, parallel_tasks);
1007+
1008+
// Larger blocks leave room for one scanner in the whole node: it goes to instance 0, and this
1009+
// instance is allocated none.
1010+
limiter->update_mem_limit(1024LL * 1024);
1011+
scanner_context->_adaptive_processor->adjust_scanners_last_timestamp = 0;
1012+
1013+
// The operator consumes one scanner's block while the other scanner is still in flight. The
1014+
// margin still asks for a task, but zero is the ceiling: the consumed scanner goes back to
1015+
// pending instead of being resubmitted.
1016+
scanner_context->_in_flight_tasks_num = 1;
1017+
const size_t pending_tasks = scanner_context->_pending_tasks.size();
1018+
auto consumed_task = std::make_shared<ScanTask>(scanners.back());
1019+
ASSERT_TRUE(
1020+
scanner_context->schedule_scan_task(consumed_task, transfer_lock, scheduler_lock).ok());
1021+
EXPECT_EQ(scanner_context->_adaptive_processor->expected_scanners, 0);
1022+
EXPECT_EQ(scanner_context->_in_flight_tasks_num, 1);
1023+
EXPECT_EQ(scanner_context->_pending_tasks.size(), pending_tasks + 1);
1024+
1025+
// With nothing occupied, one scanner still runs so the scan keeps moving.
1026+
scanner_context->_in_flight_tasks_num = 0;
1027+
ASSERT_TRUE(scanner_context->schedule_scan_task(nullptr, transfer_lock, scheduler_lock).ok());
1028+
EXPECT_EQ(scanner_context->_in_flight_tasks_num, 1);
1029+
}
1030+
9551031
TEST_F(ScannerContextTest, thread_pool_admission_holds_minimum_when_pool_saturated) {
9561032
const int parallel_tasks = 4;
9571033
auto scan_operator = std::make_unique<OlapScanOperatorX>(obj_pool.get(), tnode, 0, *descs,

0 commit comments

Comments
 (0)