Skip to content

[Bug] INSERT OVERWRITE ... PARTITION(*) hangs forever due to a receiver-side load-stream close race #65582

Description

@lzydmxy

Search before asking

  • I had searched in the issues and found no similar issues.

Version

master (also reproduces on branch-4.0 / 4.0.x). Introduced by #56120; latest master still has the buggy code.

What's Wrong?

INSERT OVERWRITE ... PARTITION(*) — and, more generally, any load that opens incremental streams at runtime — can hang forever. The FE MarkedCountDownLatch never releases, so the query blocks until the 4h insert_load_default_timeout_second. It is frequent under concurrent loads.

Which usages are affected (both go through load stream v2 / move-memtable, i.e. VTabletWriterV2, and both open incremental streams mid-load via on_partitions_created → _incremental_open_streams):

  1. auto-partition table creates a new partition at runtime (createPartition RPC):
    • INSERT INTO auto_tbl SELECT/VALUES ... where data hits a not-yet-existing partition
    • Stream Load / Broker Load / Routine Load into an auto-partition table that produces a new partition
  2. auto-detect overwrite (replacePartition RPC):
    • INSERT OVERWRITE TABLE t PARTITION(*) SELECT/VALUES ... (a static partitioned table also qualifies; with enable_auto_create_when_overwrite=true new keys additionally take path 1)

Not affected: plain loads into existing partitions, and INSERT OVERWRITE TABLE t PARTITION(p1, p2) with named partitions (normal path, no incremental stream).

An additional amplifier: _incremental_open_streams only opens a stream to a newly-appearing backend. So the hang is more likely on larger clusters / more BEs / replicas spread out, where a new partition is more likely to land on a BE that had no stream yet.

Root cause

The bug is in the receiver-side load-stream close path — _dispatch's handling of CLOSE_LOAD in be/src/load/channel/load_stream.cpp (older path: be/src/runtime/load_stream.cpp). It split one logical operation across two separate lock scopes, with a _report_result network IO (several ms) in between:

bool all_closed = close(...);          // lock scope A: count, decide all_closed
_report_result(...);                   // network IO (several ms), NO lock
std::lock_guard lock(_lock);           // lock scope B: a DIFFERENT acquisition
if (num_incremental_streams > 0) _closing_stream_ids.push_back(id);
else                             brpc::StreamClose(id);
if (all_closed) {                      // iterate + clear
    for (auto& cid : _closing_stream_ids) brpc::StreamClose(cid);
    _closing_stream_ids.clear();
}

What You Expected?

INSERT OVERWRITE ... PARTITION(*) and auto-partition loads that create partitions at runtime should complete normally; no stream should be left un-closed regardless of CLOSE_LOAD interleaving on the receiver, and no stream should be closed before its own EOS is sent.

How to Reproduce?

The race window is only microseconds on a fast single-host cluster (streams close almost instantly), so brute-force concurrency will not open it — it needs real inter-BE latency, or a deterministic delay.

Deterministic reproduction with a BE debug point (added alongside the fix, inert unless enable_debug_points=true): LoadStream.close_load.delay_incremental_register delays a non-last stream between close() and its deferred-close registration, so the last stream drains+clears the list first and orphans the delayed one.

A docker regression suite reproduces it on a 3-BE cluster (replication_num=1 + BUCKETS 1, so new partitions land on fresh backends as incremental streams):

suite("test_iot_overwrite_partition_star_hang", "docker") {
    def options = new ClusterOptions()
    options.feNum = 1; options.beNum = 3
    options.beConfigs += ['enable_debug_points=true']
    docker(options) {
        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");"""
        sql "insert into iot_star_hang values (0);"                 // base partition on ONE BE
        GetDebugPoint().enableDebugPointForAllBEs("LoadStream.close_load.delay_incremental_register")
        // one load opens non-incremental (key 0) + incremental (keys 1..49) streams
        def t = Thread.start {
            /* insert overwrite table iot_star_hang partition(*)
               select number from numbers("number"="50"); */
        }
        t.join(60000)
        if (t.isAlive()) throw new Exception("hung: orphaned incremental stream never closed")
    }
}

Result: on the buggy binary the load hangs and the suite times out; on the fixed binary the load completes.

Anything Else?

Fix approach (in the PR): a new mark_eos_sent_and_collect() merges register + all-received check + collect-to-close under a single lock; a stream is registered only after its own EOS was sent; and a write-once _all_close_load_received latch makes any thread arriving after all-received drain the remaining streams, so no orphan survives any interleaving. Fencing (closing incremental streams together, after non-incremental ones) is preserved.

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions