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

liaoxin01 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 9a259ed4748 [fix](load_stream) fix receiver-side stream leak race on 
close_load (#65584)
9a259ed4748 is described below

commit 9a259ed4748bb5de640c52e783d8324a1d687e20
Author: lizhuoyu5 <[email protected]>
AuthorDate: Mon Aug 24 23:05:00 2026 +0800

    [fix](load_stream) fix receiver-side stream leak race on close_load (#65584)
    
    Loads that open incremental destinations at runtime, including `INSERT
    OVERWRITE ... PARTITION(*)`, auto-partition creation, and auto-detect
    overwrite, could hang forever.
    
    On the receiver, `LoadStream` counted `CLOSE_LOAD` messages in `close()`
    and registered stream IDs for deferred close in a later lock scope, with
    `_report_result()` network I/O between them. Under concurrent
    `CLOSE_LOAD`s, the thread that observed `all_closed=true` could drain
    and clear `_closing_stream_ids` before another already-counted stream
    registered its ID. The late stream was then never `StreamClose`d: its
    `on_closed()` callback never fired, the sink remained in `close_wait`,
    the fragment never reported completion, and the FE latch was never
    released.
    
    This PR introduces `mark_eos_sent_and_collect()`. Deferred registration,
    the all-received check, and collection of streams to close now run under
    the same lock. The function is called after the current stream's EOS
    response write returns, while `brpc::StreamClose()` remains outside the
    lock. A write-once `_all_close_load_received` latch ensures that a
    counted-but-late thread observes that all `CLOSE_LOAD`s have arrived and
    drains its own deferred stream instead of leaving it orphaned. The
    existing close fence for loads whose sender opened incremental
    destinations is preserved.
    
    The PR adds a BE unit test and a docker regression suite. The inert
    debug point `LoadStream.close_load.delay_incremental_register`
    deterministically opens the count-before-register race window: the buggy
    implementation leaks streams and hangs, while the fixed implementation
    closes every stream and completes.
    
    ### What problem does this PR solve?
    
    Issue Number: close #65582
    
    Related PR: #56120
    
    Problem Summary:
    
    `INSERT OVERWRITE ... PARTITION(*)` and other loads that open
    incremental destinations at runtime could hang until
    `insert_load_default_timeout_second`. The receiver split `CLOSE_LOAD`
    counting and deferred-close registration across two lock scopes. A
    thread could therefore contribute to `_close_load_cnt`, pause before
    registering its stream ID, and resume only after the last-counting
    thread had already drained and cleared `_closing_stream_ids`. Because no
    later event would drain the newly registered ID, that BRPC stream
    remained open permanently and prevented the load from completing.
    
    `_closing_stream_ids` was introduced in #56120 to preserve the close
    fence required by dynamic destinations. This PR keeps that fence but
    makes registration and the all-received decision atomic under `_lock`,
    and performs the resulting `StreamClose()` calls outside the critical
    section.
    
    ### Release note
    
    Fix a permanent hang of `INSERT OVERWRITE ... PARTITION(*)` and other
    loads that open incremental destinations at runtime, caused by a
    receiver-side load-stream close race.
    
    ---------
    
    Co-authored-by: lizhuoyu5 <[email protected]>
---
 be/src/load/channel/load_stream.cpp                |  71 ++++++++---
 be/src/load/channel/load_stream.h                  |  19 ++-
 be/test/runtime/load_stream_test.cpp               |  63 +++++++++-
 .../test_iot_overwrite_partition_star_hang.out     |   6 +
 .../test_iot_overwrite_partition_star_hang.groovy  | 139 +++++++++++++++++++++
 5 files changed, 280 insertions(+), 18 deletions(-)

diff --git a/be/src/load/channel/load_stream.cpp 
b/be/src/load/channel/load_stream.cpp
index 6cae3c7852e..64c8beb0a2a 100644
--- a/be/src/load/channel/load_stream.cpp
+++ b/be/src/load/channel/load_stream.cpp
@@ -525,6 +525,39 @@ bool LoadStream::close(int64_t src_id, const 
std::vector<PTabletID>& tablets_to_
     return true;
 }
 
+std::vector<int64_t> LoadStream::mark_eos_sent_and_collect(int64_t stream_id, 
bool is_incremental) {
+    std::lock_guard<bthread::Mutex> lock_guard(_lock);
+    std::vector<int64_t> to_close;
+    // A non-incremental stream is closed as soon as its own CLOSE_LOAD (and 
EOS)
+    // is handled -- this is the first batch of streams, known up front, not 
subject
+    // to fencing. Closing it promptly also means a duplicate/late CLOSE_LOAD 
lands
+    // on an already-closed stream and is dropped, instead of being counted 
again.
+    // An incremental stream must be deferred (fencing #56120: it may only 
close once
+    // every non-incremental stream is closed), so it is parked in 
_eos_sent_stream_ids
+    // until all CLOSE_LOADs have been received.
+    if (is_incremental) {
+        // Parked only after the caller sent this stream's EOS via 
_report_result,
+        // so every parked id is safe to close.
+        _eos_sent_stream_ids.push_back(stream_id);
+    } else {
+        to_close.push_back(stream_id);
+    }
+    // `_close_load_cnt == _total_streams` means every CLOSE_LOAD has been 
counted by
+    // close(). Latch it so that any thread reaching here afterwards also 
drains the
+    // parked incremental streams, guaranteeing none is left un-closed 
regardless of
+    // thread interleaving (fixes the split-lock leak race).
+    if (_close_load_cnt >= _total_streams) {
+        _all_close_load_received = true;
+    }
+    if (_all_close_load_received) {
+        for (const auto& parked_id : _eos_sent_stream_ids) {
+            to_close.push_back(parked_id);
+        }
+        _eos_sent_stream_ids.clear();
+    }
+    return to_close;
+}
+
 void LoadStream::_report_result(StreamId stream, const Status& status,
                                 const std::vector<int64_t>& success_tablet_ids,
                                 const FailedTablets& failed_tablets, bool eos) 
{
@@ -762,24 +795,32 @@ void LoadStream::_dispatch(StreamId id, const 
PStreamHeader& hdr, butil::IOBuf*
         std::vector<int64_t> success_tablet_ids;
         FailedTablets failed_tablets;
         std::vector<PTabletID> tablets_to_commit(hdr.tablets().begin(), 
hdr.tablets().end());
-        bool all_closed =
+        // Step 1: count this CLOSE_LOAD and, if this is the last one, commit. 
Under _lock.
+        bool all_received =
                 close(hdr.src_id(), tablets_to_commit, &success_tablet_ids, 
&failed_tablets);
+        // Step 2: send THIS stream's EOS (network IO, must be outside _lock). 
A stream
+        // must not be StreamClose'd before its own EOS is delivered, 
otherwise the
+        // sender sees on_closed without EOS and reports "Stream closed 
without EOS".
         _report_result(id, Status::OK(), success_tablet_ids, failed_tablets, 
true);
-        std::lock_guard<bthread::Mutex> lock_guard(_lock);
-        // if incremental stream, we need to wait for all non-incremental 
streams to be closed
-        // before closing incremental streams. We need a fencing mechanism to 
avoid use after closing
-        // across different be.
-        if (hdr.has_num_incremental_streams() && hdr.num_incremental_streams() 
> 0) {
-            _closing_stream_ids.push_back(id);
-        } else {
-            brpc::StreamClose(id);
+        bool is_incremental =
+                hdr.has_num_incremental_streams() && 
hdr.num_incremental_streams() > 0;
+        // Test-only: delay every incremental stream except the one that made
+        // all_received, so a non-last incremental stream parks after the last
+        // stream drained the list. On the buggy code this orphans it and the
+        // load hangs; on the fix the latch drains the late registration under
+        // the same lock. Inert unless enable_debug_points=true.
+        if (is_incremental && !all_received) {
+            DBUG_EXECUTE_IF("LoadStream.close_load.delay_incremental_register",
+                            { bthread_usleep(3000000); });
         }
-
-        if (all_closed) {
-            for (auto& closing_id : _closing_stream_ids) {
-                brpc::StreamClose(closing_id);
-            }
-            _closing_stream_ids.clear();
+        // Step 3: close this stream (non-incremental) or park it for deferred 
close
+        // (incremental, fencing), then collect everything that is now safe to 
close.
+        // Registration happens only after step 2, so a collected stream 
already had
+        // its EOS delivered (fixes the close-before-EOS race); the 
all-received latch
+        // inside makes any late thread drain the parked streams (fixes the 
leak race).
+        auto streams_to_close = mark_eos_sent_and_collect(id, is_incremental);
+        for (auto& closing_id : streams_to_close) {
+            brpc::StreamClose(closing_id);
         }
     } break;
     case PStreamHeader::GET_SCHEMA: {
diff --git a/be/src/load/channel/load_stream.h 
b/be/src/load/channel/load_stream.h
index ff5c7d1d957..4c8865e1603 100644
--- a/be/src/load/channel/load_stream.h
+++ b/be/src/load/channel/load_stream.h
@@ -136,10 +136,19 @@ public:
         }
     }
 
-    // return true if all streams are closed, otherwise return false
+    // Count this CLOSE_LOAD. Returns true once CLOSE_LOAD from all streams 
has been
+    // received (i.e. the load is ready to commit). Only counts and commits; 
stream
+    // closing is handled by the caller in _dispatch to keep EOS-before-close 
ordering.
     bool close(int64_t src_id, const std::vector<PTabletID>& tablets_to_commit,
                std::vector<int64_t>* success_tablet_ids, FailedTablets* 
failed_tablet_ids);
 
+    // Close/park `stream_id` after its EOS was sent, then return stream ids 
that are
+    // now safe to close. A non-incremental stream is returned right away; an 
incremental
+    // stream is parked (fencing) until all CLOSE_LOADs arrive, after which 
any thread
+    // reaching here drains the parked streams. Only a stream whose EOS was 
sent is ever
+    // returned -- this closes both the split-lock leak race and the 
close-before-EOS race.
+    std::vector<int64_t> mark_eos_sent_and_collect(int64_t stream_id, bool 
is_incremental);
+
     // callbacks called by brpc
     int on_received_messages(StreamId id, butil::IOBuf* const messages[], 
size_t size) override;
     void on_idle_timeout(StreamId id) override;
@@ -189,7 +198,13 @@ private:
     RuntimeProfile::Counter* _close_wait_timer = nullptr;
     LoadStreamMgr* _load_stream_mgr = nullptr;
     std::shared_ptr<ResourceContext> _resource_ctx;
-    std::vector<int64_t> _closing_stream_ids;
+    // Streams whose EOS has been sent and are waiting to be closed. A stream 
is added
+    // here (under _lock) only after its _report_result() finished, and 
drained once
+    // _all_close_load_received becomes true. Replaces the old 
_closing_stream_ids whose
+    // registration happened in a separate lock scope from the all-received 
check,
+    // causing a race that leaked (never-closed) streams.
+    std::vector<int64_t> _eos_sent_stream_ids;
+    bool _all_close_load_received = false;
     bool _is_incremental = false;
 };
 
diff --git a/be/test/runtime/load_stream_test.cpp 
b/be/test/runtime/load_stream_test.cpp
index baf1f10e967..b17f302d586 100644
--- a/be/test/runtime/load_stream_test.cpp
+++ b/be/test/runtime/load_stream_test.cpp
@@ -496,13 +496,16 @@ public:
               _light_work_pool(4, 32, "load_stream_test_light") {}
 
     void close_load(MockSinkClient& client, const std::vector<PTabletID>& 
tablets_to_commit = {},
-                    uint32_t sender_id = NORMAL_SENDER_ID) {
+                    uint32_t sender_id = NORMAL_SENDER_ID, int 
num_incremental_streams = 0) {
         butil::IOBuf append_buf;
         PStreamHeader header;
         header.mutable_load_id()->set_hi(1);
         header.mutable_load_id()->set_lo(1);
         header.set_opcode(PStreamHeader::CLOSE_LOAD);
         header.set_src_id(sender_id);
+        if (num_incremental_streams > 0) {
+            header.set_num_incremental_streams(num_incremental_streams);
+        }
         for (const auto& tablet : tablets_to_commit) {
             *header.add_tablets() = tablet;
         }
@@ -1357,4 +1360,62 @@ TEST_F(LoadStreamMgrTest, 
two_client_one_close_before_the_other_open) {
     }
 }
 
+// Verify the latch drain prevents stream leaks: 3 distinct senders each
+// send one incremental CLOSE_LOAD.  The debug point delays the first 2
+// (non-last) streams while the 3rd sets the _all_close_load_received
+// latch and drains _eos_sent_stream_ids.  On the FIXED code the delayed
+// streams also drain themselves when they resume, so all 3 streams are
+// properly closed and LoadStream is cleaned up.
+//
+// Three distinct MockSinkClient connections are required because a single
+// connection shares one source entry; after the first CLOSE_LOAD consumes
+// that entry the remaining messages are rejected as "no open stream".
+TEST_F(LoadStreamMgrTest, incremental_close_race_orhpans_streams) {
+    constexpr int kStreams = 3;
+    MockSinkClient clients[kStreams];
+
+    // Open three independent senders; each advertises kStreams so that
+    // _total_streams == kStreams and add_source tracks every sender.
+    for (int i = 0; i < kStreams; i++) {
+        auto st = clients[i].connect_stream(NORMAL_SENDER_ID + i, kStreams);
+        ASSERT_TRUE(st.ok());
+    }
+    reset_response_stat();
+
+    PTabletID tablet;
+    tablet.set_partition_id(NORMAL_PARTITION_ID);
+    tablet.set_index_id(NORMAL_INDEX_ID);
+    tablet.set_tablet_id(NORMAL_TABLET_ID);
+
+    // Enable the debug point: non-last incremental streams sleep 3s.
+    auto debug_point_name = 
std::string("LoadStream.close_load.delay_incremental_register");
+    bool saved_debug_points = config::enable_debug_points;
+    config::enable_debug_points = true;
+    auto* debug_points = DebugPoints::instance();
+    debug_points->add(debug_point_name);
+    ASSERT_TRUE(debug_points->is_enable(debug_point_name)) << "debug point was 
not registered!";
+
+    // Send one incremental CLOSE_LOAD from each sender.  The last one
+    // makes _close_load_cnt == _total_streams (all_received).
+    for (int i = 0; i < kStreams; i++) {
+        close_load(clients[i], {tablet}, NORMAL_SENDER_ID + i, 1);
+    }
+
+    // At least one EOS response confirms the load ran.
+    wait_for_ack(1);
+    EXPECT_GE(g_response_stat.num, 1);
+
+    // Let the delayed streams wake up (3s sleep + small margin).
+    bthread_usleep(4 * 1000 * 1000);
+
+    // On the fixed code the latch drains all delayed streams, so no
+    // orphan remains and LoadStream is properly destroyed.
+    auto remaining = _load_stream_mgr->get_load_stream_num();
+    EXPECT_EQ(remaining, 0) << "stream leak detected! LoadStream should be 
cleaned up. "
+                            << "remaining=" << remaining;
+
+    debug_points->remove(debug_point_name);
+    config::enable_debug_points = saved_debug_points;
+}
+
 } // namespace doris
diff --git 
a/regression-test/data/insert_overwrite_p0/test_iot_overwrite_partition_star_hang.out
 
b/regression-test/data/insert_overwrite_p0/test_iot_overwrite_partition_star_hang.out
new file mode 100644
index 00000000000..abace22a8d0
--- /dev/null
+++ 
b/regression-test/data/insert_overwrite_p0/test_iot_overwrite_partition_star_hang.out
@@ -0,0 +1,6 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !sql --
+50
+
+-- !sql --
+50     0       49
diff --git 
a/regression-test/suites/insert_overwrite_p0/test_iot_overwrite_partition_star_hang.groovy
 
b/regression-test/suites/insert_overwrite_p0/test_iot_overwrite_partition_star_hang.groovy
new file mode 100644
index 00000000000..0f4f9e2b252
--- /dev/null
+++ 
b/regression-test/suites/insert_overwrite_p0/test_iot_overwrite_partition_star_hang.groovy
@@ -0,0 +1,139 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.suite.ClusterOptions
+
+// Reproduces the INSERT OVERWRITE ... PARTITION(*) hang on a multi-BE cluster.
+//
+// The race is WITHIN A SINGLE LOAD, on the receiver's per-LoadStream close 
path.
+// A PARTITION(*) auto-detect overwrite that creates NEW partitions mid-load 
opens
+// both non-incremental (base) and incremental (new partition) streams. On
+// CLOSE_LOAD the buggy _dispatch does, per stream:
+//     close();  _report_result();          // count + send this stream's EOS
+//     lock; if incremental: _closing_stream_ids.push_back(id);
+//            else: StreamClose(id);
+//            if (all_closed) { StreamClose all in _closing_stream_ids; 
clear(); }
+// The push_back of an incremental stream happens in a SEPARATE lock scope from
+// close()'s counting. If the last (non-incremental) stream reaches all_closed 
and
+// drains+clears the list before a counted-but-delayed incremental stream does 
its
+// push_back, that incremental stream is never StreamClose'd -> the load's brpc
+// streams never finish -> the load hangs forever.
+//
+// In a fast single-host docker cluster that window is microseconds, so it will
+// not open organically. The debug point 
LoadStream.close_load.delay_incremental_register
+// (BE-only, test build) sleeps an incremental stream between close() and 
push_back,
+// deterministically opening the window. On the FIXED binary registration and 
the
+// all-received check are under one lock, so no stream is orphaned and the load
+// completes even with the delay.
+//
+// Expectation: buggy binary -> load hangs -> this suite throws (RED).
+//              fixed binary -> load completes -> suite passes (GREEN).
+suite("test_iot_overwrite_partition_star_hang", "docker") {
+    def options = new ClusterOptions()
+    options.feNum = 1
+    options.beNum = 3
+    options.cloudMode = false
+    options.beConfigs += [
+        'enable_debug_points=true'
+    ]
+
+    docker(options) {
+        sql "set enable_auto_create_when_overwrite = true;"
+        sql " drop table if exists iot_star_hang; "
+        sql """
+            create table iot_star_hang(
+                k0 int null
+            )
+            auto partition by list (k0)
+            (
+                PARTITION p1 values in ((0))
+            )
+            DISTRIBUTED BY HASH(`k0`) BUCKETS 1
+            properties("replication_num" = "1");
+        """
+        // Seed ONLY the base partition. With replication_num=1 + BUCKETS 1 it 
lands on
+        // a single BE, so the base load opens a non-incremental stream to 
just one
+        // backend. New partitions created mid-overwrite land on the OTHER BEs 
as fresh
+        // backends -> those are incremental streams (num_incremental_streams 
> 0), which
+        // is exactly the condition the receiver's deferred-close race needs.
+        sql """ insert into iot_star_hang values (0); """
+
+        def deadline = 60000
+
+        try {
+            // Delay incremental-stream registration on all BEs to force the 
orphan.
+            
GetDebugPoint().enableDebugPointForAllBEs("LoadStream.close_load.delay_incremental_register")
+            log.info("debug point enabled: delay incremental register")
+
+            // Expose the JDBC statement so the watchdog can cancel the blocked
+            // socket I/O before tearing the thread down (Thread.interrupt does
+            // not reliably unblock Connector/J). Also hold the connection
+            // reference so it can be closed if a cancel arrives before the
+            // worker finishes.
+            def stmtRef = null
+            def connRef = null
+            def loadEx = null
+
+            def t = Thread.start {
+                def conn = null
+                try {
+                    conn = context.getConnection()
+                    connRef = conn
+                    def stmt = conn.createStatement()
+                    stmtRef = stmt
+                    stmt.execute("set enable_auto_create_when_overwrite = 
true;")
+                    // key 0 -> existing base partition (non-incremental 
stream);
+                    // keys 1..49 -> new partitions (incremental streams). One 
load, both kinds.
+                    stmt.execute("""
+                        insert overwrite table iot_star_hang partition(*)
+                        select number from numbers("number" = "50");
+                    """)
+                } catch (Throwable ex) {
+                    loadEx = ex
+                } finally {
+                    if (conn != null) conn.close()
+                }
+            }
+
+            t.join(deadline)
+            if (t.isAlive()) {
+                try {
+                    if (stmtRef != null) stmtRef.cancel()
+                    if (connRef != null) connRef.close()
+                } catch (Throwable ignore) {}
+                t.join(5000)
+                if (t.isAlive()) {
+                    t.interrupt()
+                }
+                throw new Exception("INSERT OVERWRITE PARTITION(*) hung: load 
did not finish within ${deadline}ms (orphaned incremental stream never closed)")
+            }
+            if (loadEx != null) {
+                throw new Exception("INSERT OVERWRITE PARTITION(*) failed: 
${loadEx.message}")
+            }
+        } finally {
+            try {
+                
GetDebugPoint().disableDebugPointForAllBEs("LoadStream.close_load.delay_incremental_register")
+            } catch (Throwable ignore) {}
+        }
+
+        // Assert the load both completed and published the correct
+        // deterministic result (keys 0..49) so the suite proves bounded
+        // completion AND correct commit visibility, not just non-hang.
+        qt_sql """select count(*) from iot_star_hang;"""
+        qt_sql """select count(distinct k0), min(k0), max(k0) from 
iot_star_hang;"""
+    }
+}


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

Reply via email to