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

morningman 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 b119273e3f0 [fix](load) Keep graceful BE stop bounded when an audit 
stream load is in flight (#66797)
b119273e3f0 is described below

commit b119273e3f06b3425a09908fc0ac65742e6a1b96
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sun Aug 16 14:35:42 2026 +0800

    [fix](load) Keep graceful BE stop bounded when an audit stream load is in 
flight (#66797)
    
    ### What problem does this PR solve?
    
    Issue Number: close #66796
    
    Problem Summary:
    
    A graceful BE shutdown could hang for up to 10 minutes and fail the
    pipeline's
    "stop grace" check.
    
    `doris_main()` stops the servers first (`Http service stopped` -> `Brpc
    service
    stopped` -> `Backend Service stopped`) and only then calls
    `ExecEnv::destroy()`,
    which reaches `SAFE_STOP(_stream_load_recorder_manager)` and joins the
    recorder's
    worker `std::thread`. If that worker is inside `_send_stream_load()` at
    the time,
    its request is a stream load against this BE's own http endpoint, which
    has
    already been torn down, so the load can never complete and the request
    only
    returns once the `DEFAULT_STREAM_LOAD_TIMEOUT_SEC = 600` curl timeout
    expires.
    The join blocks for that long. Deployment scripts give graceful stop a
    10 minute
    budget, so whenever the race fires the stop check is guaranteed to lose
    it and
    the BE gets `kill -ABRT`ed.
    
    The window is narrow (an audit batch has to be in flight within about
    one worker
    iteration of the service teardown), which is why it fired only once in
    the last
    30 NonConcurrentRegression runs, but any BE with a pending audit-log
    batch at
    stop time can hang this way.
    
    The PR applies three changes, each of which bounds the wait on its own:
    
    1. **Ordering** — `doris_main()` stops the recorder manager *before*
    tearing down
    the http service, so on the normal path the last audit batch is flushed
       against a live server and the worker is already gone by the time
       `ExecEnv::destroy()` runs. `stop()` is idempotent, so the existing
       `SAFE_STOP()` in `destroy()` becomes a no-op.
    2. **Interruptible send** — new `HttpClient::set_abort_callback()`
    registers a
    functor that libcurl polls through `CURLOPT_XFERINFOFUNCTION`, roughly
    once
    per second while the connection is idle. Returning true aborts the
    transfer
    with `CURLE_ABORTED_BY_CALLBACK` instead of running to
    `CURLOPT_TIMEOUT_MS`.
    `StreamLoadRecorderManager` hooks its `_stop` flag up to it, which
    covers both
    a request that is already in flight when `stop()` is called and one that
       races its way past the check in 3.
    3. **No new work after stop** — the worker no longer starts an audit
    load once
       shutdown has begun, and waits on a condition variable instead of an
    unconditional 1s sleep, so `stop()` wakes it immediately rather than
    after up
       to a second.
    
    `stop()` now also logs on completion. The original hang was hard to
    locate in
    the log precisely because a raw `std::thread::join()` is silent, unlike
    doris
    `Thread::join`, which prints `Waited for ...ms trying to join`.
    
    The 600s curl timeout itself is left alone: it is a sane bound for a
    load that
    is not racing shutdown, and the abort hook makes it irrelevant for
    shutdown.
    
    ### Release note
    
    Fix a graceful BE shutdown that could hang for up to 10 minutes when an
    audit
    log stream load was in flight while the BE's http service was being
    stopped.
    
    ### Check List (For Author)
    
    - Test
        - [x] Unit Test
    
    `HttpClientTest.abort_in_flight_request` points an `HttpClient` at a
    listening
    socket that is never accepted from — the kernel completes the handshake
    and
    buffers the request, but no response ever comes, which is exactly what
    an audit
      stream load looks like once the http service serving it is gone. With
    `CURLOPT_TIMEOUT_MS` at 60s, the abort callback is raised 300ms in and
    the
      request has to end well before the timeout.
    
    Verified separately against the libcurl the BE links (8.2.1) that the
    progress
      callback is polled while idle-waiting: the request ends in ~1.2s with
    `CURLE_ABORTED_BY_CALLBACK` (callback invoked 13 times) rather than at
    60s.
    
    The shutdown ordering itself is not unit-testable — it needs a full BE
    stop
    with an audit batch in flight, which is the race described in the issue.
---
 .../stream_load/stream_load_recorder_manager.cpp   | 25 ++++++++-
 .../stream_load/stream_load_recorder_manager.h     | 14 +++++
 be/src/runtime/exec_env.h                          |  3 ++
 be/src/service/doris_main.cpp                      | 10 ++++
 be/src/service/http/http_client.cpp                | 22 ++++++++
 be/src/service/http/http_client.h                  | 12 +++++
 be/test/service/http/http_client_test.cpp          | 59 ++++++++++++++++++++++
 7 files changed, 143 insertions(+), 2 deletions(-)

diff --git a/be/src/load/stream_load/stream_load_recorder_manager.cpp 
b/be/src/load/stream_load/stream_load_recorder_manager.cpp
index a8fe851efa6..76913bf8f65 100644
--- a/be/src/load/stream_load/stream_load_recorder_manager.cpp
+++ b/be/src/load/stream_load/stream_load_recorder_manager.cpp
@@ -80,9 +80,16 @@ void StreamLoadRecorderManager::_load_last_fetch_key() {
 }
 
 void StreamLoadRecorderManager::stop() {
-    _stop = true;
+    {
+        std::lock_guard<std::mutex> lock(_stop_mutex);
+        _stop = true;
+    }
+    // Wakes the worker from its idle wait, and aborts the audit stream load 
it may
+    // currently be blocked on, see the abort callback in _send_stream_load().
+    _stop_cv.notify_all();
     if (_worker_thread.joinable()) {
         _worker_thread.join();
+        LOG(INFO) << "StreamLoadRecorderManager is stopped";
     }
 }
 
@@ -90,11 +97,21 @@ void StreamLoadRecorderManager::_worker_thread_func() {
     SCOPED_ATTACH_TASK(_mem_tracker);
     while (!_stop) {
         _fetch_and_buffer_records();
+        // Do not start a new audit stream load once shutdown has begun. The 
load is served
+        // by this BE's own http service, which is about to go away.
+        if (_stop) {
+            break;
+        }
         _load_if_necessary();
-        std::this_thread::sleep_for(std::chrono::seconds(1));
+        _wait_for_stop(1000);
     }
 }
 
+void StreamLoadRecorderManager::_wait_for_stop(int64_t wait_ms) {
+    std::unique_lock<std::mutex> lock(_stop_mutex);
+    _stop_cv.wait_for(lock, std::chrono::milliseconds(wait_ms), [this]() { 
return _stop.load(); });
+}
+
 void StreamLoadRecorderManager::_fetch_and_buffer_records() {
     if (!_recorder) {
         LOG(WARNING) << "StreamLoadRecorder is not initialized";
@@ -262,6 +279,10 @@ Status StreamLoadRecorderManager::_send_stream_load(const 
std::string& data) {
     if (!st.ok()) {
         return Status::InternalError("Failed to init http client: {}", 
st.to_string());
     }
+    // This load is served by this BE's own http service. Once shutdown starts 
that service
+    // stops answering, and without an abort hook the request would sit here 
for the full
+    // DEFAULT_STREAM_LOAD_TIMEOUT_SEC, blocking the join() in stop().
+    client.set_abort_callback([this]() { return _stop.load(); });
     client.set_authorization("Basic YWRtaW46");
     client.set_header("Expect", "100-continue");
     client.set_content_type("text/plain; charset=UTF-8");
diff --git a/be/src/load/stream_load/stream_load_recorder_manager.h 
b/be/src/load/stream_load/stream_load_recorder_manager.h
index b7908b14b86..acfe76dde1a 100644
--- a/be/src/load/stream_load/stream_load_recorder_manager.h
+++ b/be/src/load/stream_load/stream_load_recorder_manager.h
@@ -18,8 +18,10 @@
 #pragma once
 
 #include <atomic>
+#include <condition_variable>
 #include <cstdint>
 #include <memory>
+#include <mutex>
 #include <string>
 #include <thread>
 
@@ -57,6 +59,13 @@ public:
 
     void start();
 
+    // Stops the worker thread and waits for it to exit. Aborts the audit 
stream load that
+    // the worker may currently be running, so that this returns within about 
a second even
+    // if the request would never be answered.
+    //
+    // The manager sends its records to this BE's own http service, so it must 
be stopped
+    // before that service is torn down. doris_main() does that explicitly; 
the SAFE_STOP()
+    // in ExecEnv::destroy() is then a no-op. Calling this more than once is 
safe.
     void stop();
 
 private:
@@ -80,12 +89,17 @@ private:
 
     void _reset_batch(int64_t current_time);
 
+    // Waits at most `wait_ms` for stop() to be called. The caller re-checks 
_stop itself.
+    void _wait_for_stop(int64_t wait_ms);
+
 private:
     std::shared_ptr<StreamLoadRecorder> _recorder;
     std::shared_ptr<MemTrackerLimiter> _mem_tracker;
 
     std::thread _worker_thread;
     std::atomic<bool> _stop;
+    std::mutex _stop_mutex;
+    std::condition_variable _stop_cv;
 
     faststring _buffer;
 
diff --git a/be/src/runtime/exec_env.h b/be/src/runtime/exec_env.h
index 885828b8eff..8d30f60258a 100644
--- a/be/src/runtime/exec_env.h
+++ b/be/src/runtime/exec_env.h
@@ -301,6 +301,9 @@ public:
 
     StreamLoadExecutor* stream_load_executor() { return 
_stream_load_executor.get(); }
     RoutineLoadTaskExecutor* routine_load_task_executor() { return 
_routine_load_task_executor; }
+    StreamLoadRecorderManager* stream_load_recorder_manager() {
+        return _stream_load_recorder_manager;
+    }
     HeartbeatFlags* heartbeat_flags() { return _heartbeat_flags; }
     FileMetaCache* file_meta_cache() { return _file_meta_cache; }
     MemTableMemoryLimiter* memtable_memory_limiter() { return 
_memtable_memory_limiter.get(); }
diff --git a/be/src/service/doris_main.cpp b/be/src/service/doris_main.cpp
index 12f5af0e2a4..dc55d6ce142 100644
--- a/be/src/service/doris_main.cpp
+++ b/be/src/service/doris_main.cpp
@@ -73,6 +73,7 @@
 #include "common/signal_handler.h"
 #include "common/status.h"
 #include "io/cache/block_file_cache_factory.h"
+#include "load/stream_load/stream_load_recorder_manager.h"
 #include "runtime/exec_env.h"
 #include "runtime/user_function_cache.h"
 #include "service/arrow_flight/flight_sql_service.h"
@@ -731,6 +732,15 @@ int main(int argc, char** argv) {
     heartbeat_thrift_starter->stop();
     heartbeat_thrift_starter->join();
     LOG(INFO) << "Heartbeat server stopped";
+    // The stream load recorder manager writes its audit records through this 
BE's own http
+    // service, so it has to be stopped while that service is still up. 
Otherwise an audit
+    // load that is in flight here can never be answered, and it blocks the 
join() done by
+    // SAFE_STOP(_stream_load_recorder_manager) in ExecEnv::destroy() for up 
to the stream
+    // load timeout, which is longer than the grace period of stop_be.sh 
--grace.
+    if (auto* recorder_manager = exec_env->stream_load_recorder_manager();
+        recorder_manager != nullptr) {
+        recorder_manager->stop();
+    }
     // TODO(zhiqiang): http_service
     http_starter->stop();
     http_starter->join();
diff --git a/be/src/service/http/http_client.cpp 
b/be/src/service/http/http_client.cpp
index 7141e6f6012..7a82549f78e 100644
--- a/be/src/service/http/http_client.cpp
+++ b/be/src/service/http/http_client.cpp
@@ -295,6 +295,8 @@ Status HttpClient::init(const std::string& url, bool 
set_fail_on_error) {
         curl_slist_free_all(_header_list);
         _header_list = nullptr;
     }
+    // curl_easy_reset() dropped the progress callback options, so drop the 
functor too.
+    _abort_callback = nullptr;
     // set error_buf
     _error_buf[0] = 0;
     auto code = curl_easy_setopt(_curl, CURLOPT_ERRORBUFFER, _error_buf);
@@ -388,6 +390,26 @@ void HttpClient::set_method(HttpMethod method) {
     }
 }
 
+void HttpClient::set_abort_callback(std::function<bool()> callback) {
+    _abort_callback = std::move(callback);
+    if (!_abort_callback) {
+        curl_easy_setopt(_curl, CURLOPT_NOPROGRESS, 1L);
+        return;
+    }
+
+    curl_xferinfo_callback xferinfo = [](void* param, curl_off_t /*dltotal*/, 
curl_off_t /*dlnow*/,
+                                         curl_off_t /*ultotal*/, curl_off_t 
/*ulnow*/) -> int {
+        auto* client = (HttpClient*)param;
+        // A non-zero return value makes libcurl abort the transfer with
+        // CURLE_ABORTED_BY_CALLBACK.
+        return client->_abort_callback() ? 1 : 0;
+    };
+    curl_easy_setopt(_curl, CURLOPT_XFERINFOFUNCTION, xferinfo);
+    curl_easy_setopt(_curl, CURLOPT_XFERINFODATA, (void*)this);
+    // libcurl only calls the progress callback when the progress meter is 
enabled.
+    curl_easy_setopt(_curl, CURLOPT_NOPROGRESS, 0L);
+}
+
 void HttpClient::set_speed_limit() {
     curl_easy_setopt(_curl, CURLOPT_LOW_SPEED_LIMIT, 
config::download_low_speed_limit_kbps * 1024);
     curl_easy_setopt(_curl, CURLOPT_LOW_SPEED_TIME, 
config::download_low_speed_time);
diff --git a/be/src/service/http/http_client.h 
b/be/src/service/http/http_client.h
index 8c57d8a2e6a..cdeaf9a4d34 100644
--- a/be/src/service/http/http_client.h
+++ b/be/src/service/http/http_client.h
@@ -117,6 +117,17 @@ public:
         curl_easy_setopt(_curl, CURLOPT_TIMEOUT_MS, timeout_ms);
     }
 
+    // Register a callback that libcurl polls while the request is in flight: 
often while
+    // data is flowing, and about once per second when the connection is idle. 
Returning
+    // true aborts the transfer right away, so `execute()` fails instead of 
blocking until
+    // CURLOPT_TIMEOUT_MS expires.
+    //
+    // This is for callers that must be able to give up on a request which may 
never be
+    // answered, e.g. a background worker that is being stopped while the http 
service
+    // serving its request is going away. Must be called after init(), which 
resets all
+    // curl options. Passing an empty callback clears a previously registered 
one.
+    void set_abort_callback(std::function<bool()> callback);
+
     // used to get content length
     // return -1 as error
     Status get_content_length(uint64_t* length) const {
@@ -198,6 +209,7 @@ private:
     CURL* _curl = nullptr;
     using HttpCallback = std::function<bool(const void* data, size_t length)>;
     const HttpCallback* _callback = nullptr;
+    std::function<bool()> _abort_callback;
     char _error_buf[CURL_ERROR_SIZE];
     curl_slist* _header_list = nullptr;
     HttpMethod _method = GET;
diff --git a/be/test/service/http/http_client_test.cpp 
b/be/test/service/http/http_client_test.cpp
index 7b6cb2fd9f7..06d23a64185 100644
--- a/be/test/service/http/http_client_test.cpp
+++ b/be/test/service/http/http_client_test.cpp
@@ -21,12 +21,18 @@
 #include <fcntl.h>
 #include <gtest/gtest-message.h>
 #include <gtest/gtest-test-part.h>
+#include <netinet/in.h>
 #include <sys/mman.h>
+#include <sys/socket.h>
 #include <sys/stat.h>
 #include <unistd.h>
 
+#include <atomic>
 #include <boost/algorithm/string/predicate.hpp>
+#include <chrono>
+#include <cstring>
 #include <filesystem>
+#include <thread>
 
 #include "gtest/gtest_pred_impl.h"
 #include "io/fs/local_file_system.h"
@@ -669,4 +675,57 @@ TEST_F(HttpClientTest, batch_download) {
     EXPECT_TRUE(st.ok());
 }
 
+TEST_F(HttpClientTest, abort_in_flight_request) {
+    // A listening socket that is never accepted from. The kernel completes 
the handshake
+    // and buffers the request, so the client believes it is connected, but no 
response ever
+    // comes back. This is what an audit stream load looks like when the http 
service that
+    // was supposed to serve it has been torn down.
+    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
+    ASSERT_GE(listen_fd, 0);
+    struct sockaddr_in addr;
+    memset(&addr, 0, sizeof(addr));
+    addr.sin_family = AF_INET;
+    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+    addr.sin_port = 0; // let the kernel pick a free port
+    ASSERT_EQ(0, bind(listen_fd, reinterpret_cast<struct sockaddr*>(&addr), 
sizeof(addr)));
+    ASSERT_EQ(0, listen(listen_fd, 8));
+    socklen_t addr_len = sizeof(addr);
+    ASSERT_EQ(0, getsockname(listen_fd, reinterpret_cast<struct 
sockaddr*>(&addr), &addr_len));
+    std::string url = "http://127.0.0.1:"; + 
std::to_string(ntohs(addr.sin_port)) + "/no_answer";
+
+    HttpClient client;
+    auto st = client.init(url);
+    EXPECT_TRUE(st.ok()) << st;
+    client.set_method(GET);
+    // Much longer than the abort is expected to take, so that finishing early 
can only be
+    // the abort callback and not the timeout.
+    client.set_timeout_ms(60 * 1000);
+    std::atomic<bool> should_abort {false};
+    client.set_abort_callback([&should_abort]() { return should_abort.load(); 
});
+
+    // Ask for the abort only once the request is on the wire, like a shutdown 
starting
+    // while a worker is already blocked inside execute().
+    std::thread aborter([&should_abort]() {
+        std::this_thread::sleep_for(std::chrono::milliseconds(300));
+        should_abort = true;
+    });
+
+    auto start = std::chrono::steady_clock::now();
+    std::string response;
+    st = client.execute(&response);
+    auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+                              std::chrono::steady_clock::now() - start)
+                              .count();
+    aborter.join();
+    close(listen_fd);
+
+    EXPECT_FALSE(st.ok());
+    // The request really was stuck waiting rather than failing outright, ...
+    EXPECT_GE(elapsed_ms, 300) << "request did not reach the server, it took " 
<< elapsed_ms
+                               << "ms";
+    // ... and the abort ended it instead of CURLOPT_TIMEOUT_MS. libcurl polls 
the callback
+    // about once a second while the connection is idle.
+    EXPECT_LT(elapsed_ms, 15000) << "request was not aborted, it took " << 
elapsed_ms << "ms";
+}
+
 } // namespace doris


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

Reply via email to