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

sollhui 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 bf127c2d9ca [fix](workload group) Drain adaptive flush callbacks 
before pool teardown (#68264)
bf127c2d9ca is described below

commit bf127c2d9ca1cdbb70c604320120fea1f1e33002
Author: hui lai <[email protected]>
AuthorDate: Mon Sep 21 16:27:33 2026 +0800

    [fix](workload group) Drain adaptive flush callbacks before pool teardown 
(#68264)
    
    ### What problem does this PR solve?
    
    Adaptive flush callbacks can outlive the ThreadPool objects they borrow.
    Workload group ID changes make teardown cancel a different registration
    name, and timer cancellation can miss a rearmed timer or free its state
    before an already-started callback acquires its mutex. These races can
    crash the backend during workload group changes or shutdown.
    
    - Save a stable, per-pool registration key and cancel it even when
    adaptive adjustment has subsequently been disabled.
    - Read the final timer ID under the callback mutex, then wait for brpc
    to finish any running callback before releasing its state.
    - Serialize registration, cancellation and stop; drain duplicate
    registrations and reject registration after stop.
    - Stop adaptive callbacks before backend pool teardown, and cancel
    registrations on direct WG scheduler destruction.
    
    ### Release note
    
    Fix backend crashes caused by adaptive flush callbacks accessing
    released thread pools during workload group changes or backend shutdown.
---
 be/src/runtime/exec_env_init.cpp                   |   7 +
 be/src/runtime/workload_group/workload_group.cpp   |  51 ++++---
 be/src/runtime/workload_group/workload_group.h     |   4 +
 .../workload_group/workload_group_manager.cpp      |   9 +-
 be/src/storage/adaptive_thread_pool_controller.cpp |  57 ++++++--
 be/src/storage/adaptive_thread_pool_controller.h   |  23 +--
 .../workload_group/workload_group_manager_test.cpp | 127 ++++++++++++++++
 .../adaptive_thread_pool_controller_test.cpp       | 162 +++++++++++++++++++++
 8 files changed, 393 insertions(+), 47 deletions(-)

diff --git a/be/src/runtime/exec_env_init.cpp b/be/src/runtime/exec_env_init.cpp
index 9ff7cc5f4e6..4833cbbf5eb 100644
--- a/be/src/runtime/exec_env_init.cpp
+++ b/be/src/runtime/exec_env_init.cpp
@@ -101,6 +101,7 @@
 #include "service/backend_options.h"
 #include "service/backend_service.h"
 #include "service/point_query_executor.h"
+#include "storage/adaptive_thread_pool_controller.h"
 #include "storage/cache/ann_index_ivf_list_cache.h"
 #include "storage/cache/page_cache.h"
 #include "storage/id_manager.h"
@@ -870,6 +871,12 @@ void ExecEnv::destroy() {
     // _routine_load_task_executor should be stopped before 
_new_load_stream_mgr.
     SAFE_STOP(_routine_load_task_executor);
     SAFE_STOP(_stream_load_recorder_manager);
+    // Adaptive callbacks borrow WG/global flush pools and the S3 upload pool.
+    // Drain them before any of these dependencies can be destroyed.
+    if (_storage_engine) {
+        _storage_engine->adaptive_thread_controller()->stop();
+    }
+
     // stop workload scheduler
     SAFE_STOP(_workload_sched_mgr);
     // Stop workload group execution threads before FragmentMgr. Running 
pipeline tasks can still
diff --git a/be/src/runtime/workload_group/workload_group.cpp 
b/be/src/runtime/workload_group/workload_group.cpp
index 76ca0fbeaaf..ecaa59972e6 100644
--- a/be/src/runtime/workload_group/workload_group.cpp
+++ b/be/src/runtime/workload_group/workload_group.cpp
@@ -29,6 +29,7 @@
 #include "cloud/config.h"
 #include "common/config.h"
 #include "common/logging.h"
+#include "cpp/sync_point.h"
 #include "exec/pipeline/task_queue.h"
 #include "exec/pipeline/task_scheduler.h"
 #include "exec/scan/scanner_scheduler.h"
@@ -568,10 +569,15 @@ Status 
WorkloadGroup::upsert_thread_pool_no_lock(WorkloadGroupInfo* wg_info,
                 std::make_unique<HybridTaskScheduler>(pipeline_exec_thread_num,
                                                       
blocking_exec_thread_num, "p_" + wg_name,
                                                       cg_cpu_ctl_ptr);
-        Status ret = pipeline_task_scheduler->start();
+        Status ret = SYNC_POINT_HOOK_RETURN_VALUE(
+                pipeline_task_scheduler->start(),
+                
"WorkloadGroup::upsert_thread_pool_no_lock::task_scheduler_start");
         if (ret.ok()) {
             _task_sched = std::move(pipeline_task_scheduler);
         } else {
+            // A failed start may leave only some schedulers running. Stop all 
of
+            // them before destruction, which requires both schedulers to be 
shut down.
+            pipeline_task_scheduler->stop();
             upsert_ret = ret;
             LOG(INFO) << "[upsert wg thread pool] task scheduler start failed, 
gid= " << wg_id;
         }
@@ -634,17 +640,7 @@ Status 
WorkloadGroup::upsert_thread_pool_no_lock(WorkloadGroupInfo* wg_info,
             LOG(INFO) << "[upsert wg thread pool] create " + pool_name + " 
succ, gid=" << wg_id
                       << ", max thread num=" << max_flush_thread_num
                       << ", min thread num=" << min_flush_thread_num;
-            // Register the new pool with adaptive thread controller
-            if (config::enable_adaptive_flush_threads) {
-                auto* controller =
-                        
ExecEnv::GetInstance()->storage_engine().adaptive_thread_controller();
-                auto* flush_pool = _memtable_flush_pool.get();
-                controller->add("flush_wg_" + std::to_string(_id), 
{flush_pool},
-                                
AdaptiveThreadPoolController::make_flush_adjust_func(controller,
-                                                                               
      flush_pool),
-                                config::max_flush_thread_num_per_cpu,
-                                config::min_flush_thread_num_per_cpu);
-            }
+            register_adaptive_flush_no_lock();
         } else {
             upsert_ret = ret;
             LOG(INFO) << "[upsert wg thread pool] create " + pool_name + " 
failed, gid=" << wg_id;
@@ -778,21 +774,36 @@ void WorkloadGroup::stop_schedulers_no_lock() {
         _remote_scan_task_sched->stop();
     }
     if (_memtable_flush_pool) {
-        // Unregister from adaptive controller before destroying the pool to 
avoid UAF:
-        // the adjustment loop holds raw ThreadPool* pointers and must not 
access them
-        // after the pool is gone.
-        if (config::enable_adaptive_flush_threads) {
-            auto* controller =
-                    
ExecEnv::GetInstance()->storage_engine().adaptive_thread_controller();
-            controller->cancel("flush_wg_" + std::to_string(_id));
-        }
+        cancel_adaptive_flush_no_lock();
         _memtable_flush_pool->shutdown();
         _memtable_flush_pool->wait();
     }
 }
 
+void WorkloadGroup::register_adaptive_flush_no_lock() {
+    if (config::enable_adaptive_flush_threads) {
+        auto* controller = 
ExecEnv::GetInstance()->storage_engine().adaptive_thread_controller();
+        auto* flush_pool = _memtable_flush_pool.get();
+        _adaptive_flush_key = fmt::format("flush_wg_{}_{}", _id, 
fmt::ptr(flush_pool));
+        controller->add(
+                _adaptive_flush_key, {flush_pool},
+                
AdaptiveThreadPoolController::make_flush_adjust_func(controller, flush_pool),
+                config::max_flush_thread_num_per_cpu, 
config::min_flush_thread_num_per_cpu);
+    }
+}
+
+void WorkloadGroup::cancel_adaptive_flush_no_lock() {
+    if (!_adaptive_flush_key.empty()) {
+        auto* controller = 
ExecEnv::GetInstance()->storage_engine().adaptive_thread_controller();
+        // A runtime config change must not skip cancellation of an existing 
registration.
+        controller->cancel(_adaptive_flush_key);
+        _adaptive_flush_key.clear();
+    }
+}
+
 void WorkloadGroup::destroy_schedulers() {
     std::lock_guard<std::shared_mutex> wlock(_task_sched_lock);
+    cancel_adaptive_flush_no_lock();
     _task_sched.reset();
     _scan_task_sched.reset();
     _remote_scan_task_sched.reset();
diff --git a/be/src/runtime/workload_group/workload_group.h 
b/be/src/runtime/workload_group/workload_group.h
index bff74bdeef5..a82097c14c8 100644
--- a/be/src/runtime/workload_group/workload_group.h
+++ b/be/src/runtime/workload_group/workload_group.h
@@ -220,6 +220,8 @@ private:
     void upsert_cgroup_cpu_ctl_no_lock(WorkloadGroupInfo* wg_info);
     Status upsert_thread_pool_no_lock(WorkloadGroupInfo* wg_info,
                                       std::shared_ptr<CgroupCpuCtl> 
cg_cpu_ctl_ptr);
+    void register_adaptive_flush_no_lock();
+    void cancel_adaptive_flush_no_lock();
     void stop_schedulers_no_lock();
     void destroy_schedulers();
 
@@ -263,6 +265,8 @@ private:
     std::unique_ptr<ScannerScheduler> _scan_task_sched {nullptr};
     std::unique_ptr<ScannerScheduler> _remote_scan_task_sched {nullptr};
     std::unique_ptr<ThreadPool> _memtable_flush_pool {nullptr};
+    // Registration identity must survive normal WG ID changes and ID reuse.
+    std::string _adaptive_flush_key;
 
     std::map<std::string, std::shared_ptr<IOThrottle>> _scan_io_throttle_map;
     std::shared_ptr<IOThrottle> _remote_scan_io_throttle {nullptr};
diff --git a/be/src/runtime/workload_group/workload_group_manager.cpp 
b/be/src/runtime/workload_group/workload_group_manager.cpp
index 2a283f29972..ed4ce5bd0d0 100644
--- a/be/src/runtime/workload_group/workload_group_manager.cpp
+++ b/be/src/runtime/workload_group/workload_group_manager.cpp
@@ -992,7 +992,14 @@ Status WorkloadGroupMgr::create_internal_wg() {
     WorkloadGroupInfo wg_info = WorkloadGroupInfo::parse_topic_info(twg_info);
     auto normal_wg = std::make_shared<WorkloadGroup>(wg_info);
 
-    RETURN_IF_ERROR(normal_wg->upsert_task_scheduler(&wg_info));
+    auto status = normal_wg->upsert_task_scheduler(&wg_info);
+    if (!status.ok()) {
+        // A later pool may have started and registered an adaptive callback 
even
+        // when an earlier scheduler failed. This WG is not owned by the 
manager
+        // yet, so drain its callbacks before the local shared_ptr releases it.
+        normal_wg->try_stop_schedulers();
+        return status;
+    }
 
     {
         std::lock_guard<std::shared_mutex> w_lock(_group_mutex);
diff --git a/be/src/storage/adaptive_thread_pool_controller.cpp 
b/be/src/storage/adaptive_thread_pool_controller.cpp
index bcb7b089389..405d8fcf0cc 100644
--- a/be/src/storage/adaptive_thread_pool_controller.cpp
+++ b/be/src/storage/adaptive_thread_pool_controller.cpp
@@ -20,6 +20,7 @@
 #include <butil/time.h>
 
 #include <algorithm>
+#include <chrono>
 #include <thread>
 
 #include "cloud/config.h"
@@ -27,6 +28,7 @@
 #include "common/logging.h"
 #include "common/metrics/system_metrics.h"
 #include "common/status.h"
+#include "cpp/sync_point.h"
 #include "util/threadpool.h"
 #include "util/time.h"
 
@@ -49,14 +51,13 @@ int 
AdaptiveThreadPoolController::PoolGroup::get_min_threads() const {
 void AdaptiveThreadPoolController::_on_timer(void* raw) {
     auto* arg = static_cast<TimerArg*>(raw);
 
-    // Hold mu for the entire callback (fire + re-registration).
-    // cancel() acquires mu after bthread_timer_del, so this provides
-    // cancel-with-wait semantics without a dedicated thread.
+    TEST_SYNC_POINT("AdaptiveThreadPoolController::callback_entered");
+
+    // Keep registration and adjustment serialized with cancellation.
     std::lock_guard<std::mutex> lk(arg->mu);
 
     if (arg->stopped.load(std::memory_order_acquire)) {
-        // cancel() set stopped before we took the lock.
-        // cancel() owns arg and will delete it after taking mu.
+        // cancel() joins this timer before deleting arg.
         return;
     }
 
@@ -66,6 +67,8 @@ void AdaptiveThreadPoolController::_on_timer(void* raw) {
         return; // cancel() will clean up
     }
 
+    TEST_SYNC_POINT("AdaptiveThreadPoolController::before_rearm");
+
     // Re-register the next one-shot timer.
     bthread_timer_t tid;
     if (bthread_timer_add(&tid, 
butil::milliseconds_from_now(arg->interval_ms), _on_timer, arg) ==
@@ -83,6 +86,8 @@ void AdaptiveThreadPoolController::init(SystemMetrics* 
system_metrics,
 }
 
 void AdaptiveThreadPoolController::stop() {
+    std::lock_guard<std::mutex> lifecycle_lock(_lifecycle_mutex);
+    _stopped = true;
     std::vector<std::string> names;
     {
         std::lock_guard<std::mutex> lk(_mutex);
@@ -91,13 +96,19 @@ void AdaptiveThreadPoolController::stop() {
         }
     }
     for (const auto& name : names) {
-        cancel(name);
+        _cancel(name);
     }
 }
 
 void AdaptiveThreadPoolController::add(std::string name, 
std::vector<ThreadPool*> pools,
                                        AdjustFunc adjust_func, double 
max_threads_per_cpu,
                                        double min_threads_per_cpu, int64_t 
interval_ms) {
+    std::lock_guard<std::mutex> lifecycle_lock(_lifecycle_mutex);
+    if (_stopped) {
+        return;
+    }
+    _cancel(name);
+
     PoolGroup group;
     group.name = name;
     group.pools = std::move(pools);
@@ -114,6 +125,8 @@ void AdaptiveThreadPoolController::add(std::string name, 
std::vector<ThreadPool*
     arg->name = name;
     arg->interval_ms = interval_ms;
 
+    // Even an immediately due timer must not run before its ID and group are 
published.
+    std::lock_guard<std::mutex> timer_lock(arg->mu);
     bthread_timer_t tid;
     if (bthread_timer_add(&tid, butil::milliseconds_from_now(interval_ms), 
_on_timer, arg) == 0) {
         arg->timer_id.store(tid, std::memory_order_release);
@@ -133,6 +146,11 @@ void AdaptiveThreadPoolController::add(std::string name, 
std::vector<ThreadPool*
 }
 
 void AdaptiveThreadPoolController::cancel(const std::string& name) {
+    std::lock_guard<std::mutex> lifecycle_lock(_lifecycle_mutex);
+    _cancel(name);
+}
+
+void AdaptiveThreadPoolController::_cancel(const std::string& name) {
     TimerArg* arg = nullptr;
     {
         std::lock_guard<std::mutex> lk(_mutex);
@@ -149,18 +167,24 @@ void AdaptiveThreadPoolController::cancel(const 
std::string& name) {
 
     // Signal the callback to stop re-registering.
     arg->stopped.store(true, std::memory_order_release);
+    // Once per removed registration, after stopped is visible. Cancelling a
+    // missing registration must not reach this synchronization point.
+    TEST_SYNC_POINT("AdaptiveThreadPoolController::cancel_stopped");
 
-    // Try to cancel a pending (not yet fired) timer. Read timer_id after
-    // setting stopped so any re-registration in a concurrent callback has
-    // already stored the latest id by now (it holds mu, which we haven't
-    // taken yet).
-    bthread_timer_t tid = arg->timer_id.load(std::memory_order_acquire);
-    bthread_timer_del(tid); // returns non-zero if already fired; that's fine
+    // A callback may have passed its stopped check and still be 
re-registering.
+    // Take mu before reading the final ID, rather than cancelling a stale ID.
+    bthread_timer_t tid;
+    {
+        std::lock_guard<std::mutex> lk(arg->mu);
+        tid = arg->timer_id.load(std::memory_order_acquire);
+    }
 
-    // Wait for any in-flight callback to finish. The callback holds mu while
-    // running _fire_group and re-registering, so acquiring mu here ensures
-    // we don't free arg while the callback is still executing.
-    { std::lock_guard<std::mutex> lk(arg->mu); }
+    // The timer can already be running without having acquired mu. Joining via
+    // brpc's running state covers that window too. Do not hold mu while 
waiting:
+    // such a callback must acquire it, observe stopped and return.
+    while (tid != 0 && bthread_timer_del(tid) == 1) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(1));
+    }
 
     delete arg;
     LOG(INFO) << "Adaptive: cancelled pool group '" << name << "'";
@@ -198,6 +222,7 @@ void AdaptiveThreadPoolController::_fire_group(const 
std::string& name) {
 
 // Fire all groups once regardless of schedule. For testing.
 void AdaptiveThreadPoolController::adjust_once() {
+    std::lock_guard<std::mutex> lifecycle_lock(_lifecycle_mutex);
     std::vector<std::string> names;
     {
         std::lock_guard<std::mutex> lk(_mutex);
diff --git a/be/src/storage/adaptive_thread_pool_controller.h 
b/be/src/storage/adaptive_thread_pool_controller.h
index ae78b7883dd..792259fa98b 100644
--- a/be/src/storage/adaptive_thread_pool_controller.h
+++ b/be/src/storage/adaptive_thread_pool_controller.h
@@ -40,18 +40,14 @@ struct TimerArg {
     std::string name;
     int64_t interval_ms;
 
-    // Set by cancel() before calling bthread_timer_del. The callback checks
-    // this flag after acquiring `mu` and skips re-registration when true.
+    // Set before cancel() acquires mu, preventing further 
adjustment/re-registration.
     std::atomic<bool> stopped {false};
 
-    // Tracks the most recently registered timer id. Updated under `mu` by the
-    // callback after each re-registration; read by cancel() to call
-    // bthread_timer_del on the latest pending timer.
+    // Updated and read under mu, including the initial registration in add().
     std::atomic<bthread_timer_t> timer_id {0};
 
-    // Held for the entire duration of the callback (fire + re-registration).
-    // cancel() acquires it after bthread_timer_del to wait for any in-flight
-    // invocation to complete before freeing `this`.
+    // Serializes initial registration, adjustment, re-registration and 
cancellation.
+    // Taking this lock alone does not join a callback that has not acquired 
it yet.
     std::mutex mu;
 };
 
@@ -88,10 +84,11 @@ public:
     // Initialize with system-level dependencies.
     void init(SystemMetrics* system_metrics, ThreadPool* s3_file_upload_pool);
 
-    // Cancel all registered pool groups. Must be called before the pools are 
destroyed.
+    // Permanently stop registration and cancel all groups before pools are 
destroyed.
     void stop();
 
-    // Register a pool group and start a recurring bthread_timer_add chain.
+    // Register a timer chain, draining an existing registration with the same 
name.
+    // Lifecycle methods must not be called from an AdjustFunc.
     void add(std::string name, std::vector<ThreadPool*> pools, AdjustFunc 
adjust_func,
              double max_threads_per_cpu, double min_threads_per_cpu,
              int64_t interval_ms = kDefaultIntervalMs);
@@ -137,10 +134,16 @@ private:
 
     void _apply_thread_count(PoolGroup& group, int target_threads, const 
std::string& reason);
 
+    // Requires _lifecycle_mutex.
+    void _cancel(const std::string& name);
+
 private:
     SystemMetrics* _system_metrics = nullptr;
     ThreadPool* _s3_file_upload_pool = nullptr;
 
+    // Serializes add/cancel/stop so concurrent teardown also waits for 
cancellation.
+    std::mutex _lifecycle_mutex;
+    bool _stopped = false;
     mutable std::mutex _mutex;
     mutable std::mutex _metrics_state_mutex;
     std::map<std::string, PoolGroup> _pool_groups;
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 bf7bc9e57d1..03a9cedae3d 100644
--- a/be/test/runtime/workload_group/workload_group_manager_test.cpp
+++ b/be/test/runtime/workload_group/workload_group_manager_test.cpp
@@ -29,9 +29,11 @@
 #include <filesystem>
 #include <memory>
 #include <sstream>
+#include <tuple>
 
 #include "common/config.h"
 #include "common/status.h"
+#include "cpp/sync_point.h"
 #include "exec/pipeline/dependency.h"
 #include "exec/spill/spill_file_manager.h"
 #include "load/memtable/memtable_memory_limiter.h"
@@ -40,10 +42,13 @@
 #include "runtime/runtime_query_statistics_mgr.h"
 #include "runtime/thread_context.h"
 #include "runtime/workload_group/workload_group.h"
+#include "storage/adaptive_thread_pool_controller.h"
 #include "storage/olap_define.h"
+#include "storage/storage_engine.h"
 #include "testutil/mock/mock_query_task_controller.h"
 #include "util/defer_op.h"
 #include "util/mem_info.h"
+#include "util/threadpool.h"
 
 namespace doris {
 
@@ -1231,4 +1236,126 @@ TEST_F(WorkloadGroupManagerTest, 
phase4_skips_cancelled_query_memory_exceeded) {
     live_query->query_mem_tracker()->consume(-1024 * 4);
 }
 
+TEST_F(WorkloadGroupManagerTest, FailedInternalGroupSetupCancelsAdaptiveFlush) 
{
+    auto* env = ExecEnv::GetInstance();
+    auto saved_engine = std::move(env->_storage_engine);
+    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 {[&] {
+        env->set_storage_engine(std::move(saved_engine));
+        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;
+    }};
+    env->set_storage_engine(std::make_unique<StorageEngine>(EngineOptions {}));
+    auto* controller = env->storage_engine().adaptive_thread_controller();
+    config::enable_adaptive_flush_threads = true;
+    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;
+
+    auto* sp = SyncPoint::get_instance();
+    sp->enable_processing();
+    Defer disable_sync_points {[&] { sp->disable_processing(); }};
+    int failed_starts = 0;
+    int cancelled_registrations = 0;
+    SyncPoint::CallbackGuard start_guard;
+    SyncPoint::CallbackGuard cancel_guard;
+    sp->set_call_back(
+            "WorkloadGroup::upsert_thread_pool_no_lock::task_scheduler_start",
+            [&](auto&& args) {
+                auto* result = try_any_cast_ret<Status>(args);
+                result->first = Status::InternalError<false>("injected 
pipeline scheduler failure");
+                result->second = true;
+                ++failed_starts;
+            },
+            &start_guard);
+    sp->set_call_back(
+            "AdaptiveThreadPoolController::cancel_stopped",
+            [&](auto&&) { ++cancelled_registrations; }, &cancel_guard);
+
+    const auto status = _wg_manager->create_internal_wg();
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("injected pipeline scheduler failure"), 
std::string::npos);
+    EXPECT_EQ(failed_starts, 1);
+    EXPECT_TRUE(_wg_manager->_workload_groups.empty());
+    // The flush pool was registered despite the earlier scheduler failure, and
+    // must be cancelled even though the WG never entered the manager's map.
+    EXPECT_EQ(cancelled_registrations, 1);
+    {
+        std::lock_guard<std::mutex> lock(controller->_mutex);
+        EXPECT_TRUE(controller->_pool_groups.empty());
+    }
+    // Also clean up if an assertion above detects a missing cancellation.
+    controller->stop();
+}
+
+// Exercise the actual registration/cancellation paths without starting query 
schedulers.
+TEST_F(WorkloadGroupManagerTest, 
AdaptiveFlushRegistrationSurvivesIdChangeAndReuse) {
+    auto* env = ExecEnv::GetInstance();
+    auto saved_engine = std::move(env->_storage_engine);
+    const bool saved_adaptive = config::enable_adaptive_flush_threads;
+    Defer restore {[&] {
+        env->set_storage_engine(std::move(saved_engine));
+        config::enable_adaptive_flush_threads = saved_adaptive;
+    }};
+    env->set_storage_engine(std::make_unique<StorageEngine>(EngineOptions {}));
+    auto* controller = env->storage_engine().adaptive_thread_controller();
+    config::enable_adaptive_flush_threads = true;
+
+    auto wg = _wg_manager->get_or_create_workload_group({.id = 1, .name = 
"normal"});
+    ASSERT_TRUE(ThreadPoolBuilder("wg_flush_test")
+                        .set_min_threads(1)
+                        .set_max_threads(2)
+                        .build(&wg->_memtable_flush_pool)
+                        .ok());
+    wg->register_adaptive_flush_no_lock();
+    const auto key = wg->_adaptive_flush_key;
+    ASSERT_GT(controller->get_current_threads(key), 0);
+    _wg_manager->reset_workload_group_id("normal", 100);
+    EXPECT_EQ(wg->id(), 100);
+    EXPECT_EQ(wg->_adaptive_flush_key, key);
+
+    // A second WG using the original ID must not replace the first 
registration.
+    auto reused = _wg_manager->get_or_create_workload_group({.id = 1, .name = 
"reused"});
+    ASSERT_TRUE(ThreadPoolBuilder("wg_flush_reused")
+                        .set_min_threads(1)
+                        .set_max_threads(2)
+                        .build(&reused->_memtable_flush_pool)
+                        .ok());
+    reused->register_adaptive_flush_no_lock();
+    const auto reused_key = reused->_adaptive_flush_key;
+    EXPECT_NE(key, reused_key);
+
+    // Disabling adjustment must not disable cleanup of already registered 
pools.
+    config::enable_adaptive_flush_threads = false;
+    wg->try_stop_schedulers();
+    wg->destroy_schedulers();
+    EXPECT_EQ(controller->get_current_threads(key), 0);
+    EXPECT_GT(controller->get_current_threads(reused_key), 0);
+    // Direct scheduler destruction must also drain the registration.
+    reused->destroy_schedulers();
+    EXPECT_EQ(controller->get_current_threads(reused_key), 0);
+    config::enable_adaptive_flush_threads = true;
+    controller->adjust_once();
+}
+
 } // namespace doris
diff --git a/be/test/storage/adaptive_thread_pool_controller_test.cpp 
b/be/test/storage/adaptive_thread_pool_controller_test.cpp
index 8e3b34841f8..1590ff5411d 100644
--- a/be/test/storage/adaptive_thread_pool_controller_test.cpp
+++ b/be/test/storage/adaptive_thread_pool_controller_test.cpp
@@ -20,12 +20,16 @@
 #include <gtest/gtest.h>
 
 #include <algorithm>
+#include <atomic>
+#include <future>
 #include <thread>
 
 #include "common/config.h"
 #include "common/metrics/metrics.h"
 #include "common/metrics/system_metrics.h"
+#include "cpp/sync_point.h"
 #include "testutil/test_util.h"
+#include "util/defer_op.h"
 #include "util/threadpool.h"
 
 namespace doris {
@@ -68,6 +72,71 @@ protected:
         if (_pool2) _pool2->shutdown();
     }
 
+    void check_cancel_race(const std::string& point) {
+        config::enable_adaptive_flush_threads = true;
+        auto* sp = SyncPoint::get_instance();
+        sp->enable_processing();
+        Defer disable_sync_points {[&] { sp->disable_processing(); }};
+        std::promise<void> entered;
+        std::promise<void> release;
+        std::promise<void> cancelling;
+        auto entered_future = entered.get_future();
+        auto release_future = release.get_future().share();
+        auto cancelling_future = cancelling.get_future();
+        std::atomic<int> entered_calls {0};
+        std::atomic<int> cancellations {0};
+        // Remove callbacks before destroying the state they capture.
+        SyncPoint::CallbackGuard entered_guard;
+        SyncPoint::CallbackGuard cancelling_guard;
+        sp->set_call_back(
+                point,
+                [&](auto&&) {
+                    // A timeout can release the callback before cancellation 
starts.
+                    // Report repeated entries through the count instead of 
throwing.
+                    if (entered_calls.fetch_add(1) == 0) {
+                        entered.set_value();
+                    }
+                    release_future.wait();
+                },
+                &entered_guard);
+        sp->set_call_back(
+                "AdaptiveThreadPoolController::cancel_stopped",
+                [&](auto&&) {
+                    if (cancellations.fetch_add(1) == 0) {
+                        cancelling.set_value();
+                    }
+                },
+                &cancelling_guard);
+
+        AdaptiveThreadPoolController controller;
+        controller.add(
+                "race", {_pool.get()},
+                
AdaptiveThreadPoolController::make_flush_adjust_func(&controller, _pool.get()), 
4,
+                0.5, 1);
+        // Always release the callback before joining/stopping, including on 
test failure.
+        if (entered_future.wait_for(std::chrono::seconds(5)) != 
std::future_status::ready) {
+            release.set_value();
+            controller.stop();
+            FAIL() << "Timer did not reach " << point;
+        }
+        auto cancelled = std::async(std::launch::async, [&] { 
controller.cancel("race"); });
+        auto cancelling_status = 
cancelling_future.wait_for(std::chrono::seconds(5));
+        EXPECT_EQ(cancelling_status, std::future_status::ready);
+        EXPECT_EQ(cancelled.wait_for(std::chrono::milliseconds(20)), 
std::future_status::timeout);
+        auto second_cancel = std::async(std::launch::async, [&] { 
controller.cancel("race"); });
+        EXPECT_EQ(second_cancel.wait_for(std::chrono::milliseconds(20)),
+                  std::future_status::timeout);
+        release.set_value();
+        cancelled.get();
+        second_cancel.get();
+        EXPECT_EQ(entered_calls.load(), 1);
+        EXPECT_EQ(cancellations.load(), 1);
+        EXPECT_EQ(controller.get_current_threads("race"), 0);
+        _pool.reset();
+        // A timer rearmed after the final stopped check must have been 
cancelled too.
+        std::this_thread::sleep_for(std::chrono::milliseconds(20));
+    }
+
     bool _original_enable_adaptive;
     std::unique_ptr<ThreadPool> _pool;
     std::unique_ptr<ThreadPool> _pool2;
@@ -354,4 +423,97 @@ TEST_F(AdaptiveThreadPoolControllerTest, TestCancel) {
     EXPECT_EQ(controller.get_current_threads("test"), 0);
 }
 
+TEST_F(AdaptiveThreadPoolControllerTest, CancelJoinsCallbackBeforeLock) {
+    check_cancel_race("AdaptiveThreadPoolController::callback_entered");
+}
+
+TEST_F(AdaptiveThreadPoolControllerTest, CancelJoinsRearmingCallback) {
+    check_cancel_race("AdaptiveThreadPoolController::before_rearm");
+}
+
+TEST_F(AdaptiveThreadPoolControllerTest, StopRejectsNewRegistrations) {
+    AdaptiveThreadPoolController controller;
+    controller.stop();
+    controller.add("late", {_pool.get()},
+                   
AdaptiveThreadPoolController::make_flush_adjust_func(&controller, _pool.get()),
+                   4, 0.5, 1);
+    EXPECT_EQ(controller.get_current_threads("late"), 0);
+}
+
+TEST_F(AdaptiveThreadPoolControllerTest, ReplacingRegistrationDrainsOldTimer) {
+    config::enable_adaptive_flush_threads = true;
+    auto* sp = SyncPoint::get_instance();
+    sp->enable_processing();
+    Defer disable_sync_points {[&] { sp->disable_processing(); }};
+    std::promise<void> entered;
+    std::promise<void> release;
+    std::promise<void> cancelling;
+    auto entered_future = entered.get_future();
+    auto release_future = release.get_future().share();
+    auto cancelling_future = cancelling.get_future();
+    std::atomic<int> old_calls {0};
+    std::atomic<int> new_calls {0};
+    std::atomic<bool> old_callback_finished {false};
+    std::atomic<int> cancellations {0};
+    SyncPoint::CallbackGuard cancelling_guard;
+    sp->set_call_back(
+            "AdaptiveThreadPoolController::cancel_stopped",
+            [&](auto&&) {
+                if (cancellations.fetch_add(1) == 0) {
+                    cancelling.set_value();
+                }
+            },
+            &cancelling_guard);
+
+    AdaptiveThreadPoolController controller;
+    controller.add(
+            "same", {_pool.get()},
+            [&, pool = _pool.get()](int current, int, int, std::string&) {
+                if (old_calls.fetch_add(1) == 0) {
+                    entered.set_value();
+                }
+                release_future.wait();
+                EXPECT_EQ(pool->get_queue_size(), 0);
+                old_callback_finished.store(true);
+                return current;
+            },
+            4, 0.5, 1);
+    if (entered_future.wait_for(std::chrono::seconds(5)) != 
std::future_status::ready) {
+        release.set_value();
+        controller.stop();
+        FAIL() << "Old timer did not enter its adjustment callback";
+    }
+
+    auto replaced = std::async(std::launch::async, [&] {
+        controller.add(
+                "same", {_pool2.get()},
+                [&](int, int min_t, int, std::string&) {
+                    new_calls.fetch_add(1);
+                    return min_t;
+                },
+                4, 0.5, 60000);
+        EXPECT_TRUE(old_callback_finished.load())
+                << "Replacement returned before the old callback finished";
+    });
+    // Wait for replacement to actually start cancellation, not merely for its
+    // worker to be scheduled. The old AdjustFunc stays blocked until released.
+    EXPECT_EQ(cancelling_future.wait_for(std::chrono::seconds(5)), 
std::future_status::ready);
+    EXPECT_EQ(replaced.wait_for(std::chrono::milliseconds(0)), 
std::future_status::timeout);
+    EXPECT_FALSE(old_callback_finished.load());
+    release.set_value();
+    replaced.get();
+
+    EXPECT_TRUE(old_callback_finished.load());
+    EXPECT_EQ(old_calls.load(), 1);
+    EXPECT_EQ(new_calls.load(), 0);
+    _pool.reset();
+    controller.adjust_once();
+    EXPECT_EQ(new_calls.load(), 1);
+    EXPECT_EQ(old_calls.load(), 1);
+    controller.cancel("same");
+    // Check registration removal; the barriers above verify callback 
completion.
+    EXPECT_EQ(controller.get_current_threads("same"), 0);
+    _pool2.reset();
+}
+
 } // namespace doris


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

Reply via email to