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

mrhhsg 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 d35788599ba [fix](be) Give local runtime filter consumers a private 
wrapper when the filter is also merged (#68684)
d35788599ba is described below

commit d35788599ba43835615c9c3bbb9813b551157a94
Author: Jerry Hu <[email protected]>
AuthorDate: Wed Oct 14 14:24:51 2026 +0800

    [fix](be) Give local runtime filter consumers a private wrapper when the 
filter is also merged (#68684)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Problem Summary:
    
    A runtime filter without a remote target can still have two kinds of
    consumers at once: plain local-target consumers registered in the local
    RF mgr, and local-merge-target consumers registered in the global RF
    mgr. `RuntimeFilterProducer::publish()` hands the same
    `RuntimeFilterWrapper` to both: the local merger, which used to take
    over the first producer's wrapper (`_wrapper = other->_wrapper`) and
    keep merging later producers into it, and the local consumer, which
    immediately starts probing it.
    
    There is no mutual exclusion between the two. The merger mutates the
    wrapper (inserting into the hybrid set, clearing it on IN-overflow,
    merging the bloom filter directory) under its own lock while a local
    consumer's scan thread may be concurrently reading the very same object.
    Depending on timing this can:
    - hit `DORIS_CHECK(!_bucket_prune_hashes_started)` once the local
    consumer has built its bucket-pruning cache, aborting the process on
    ASAN/Debug builds or failing the query on Release builds,
    - clear the IN set a local consumer is still probing once the merged set
    exceeds `runtime_filter_max_in_num`, silently turning its filter into
    "match nothing" and dropping rows,
    - race on the hybrid set / bloom filter directory memory itself.
    
    This mix of target kinds is an existing, intentional configuration (see
    the comment above `publish()`), not a misconfiguration. Broadcast joins
    which share one hash table publish the same wrapper from every instance,
    so the merger even merged that wrapper into itself.
    
    Fix: establish the invariant that the merger never writes a producer's
    wrapper.
    - `RuntimeFilterMerger::merge_from()` takes a deep copy
    (`RuntimeFilterWrapper::clone()`) of the first wrapper it receives and
    merges all later producers into that private copy. Producers' wrappers
    are only read by the merger, so the consumers in local RF mgr can keep
    using them, and the shared wrapper of a broadcast join is never
    modified.
    - `RuntimeFilterWrapper::merge()` used to take over the bloom filter of
    the input (`_bloom_filter_func = other->_bloom_filter_func`) when an
    IN_OR_BLOOM filter which is still an IN filter meets a bloom filter, and
    then inserted its own IN set into it — a write to another producer's
    wrapper. It now copies the input bloom filter first.
    - `RuntimeFilterWrapper::clone()` (with `MinMaxFuncBase::clone()` and
    `BloomFilterFuncBase::clone()`) makes the deep copy. The copy of an
    IN_OR_BLOOM filter which is still an IN filter does not copy the unused
    bloom filter: a later merge either re-initializes it or takes a copy of
    the input one.
    
    Cost: one additional deep copy per merged filter (the first product),
    both for the local merge on each BE and for the first filter arriving at
    the remote merge node. It is the same work as one more merge.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test:
    - Unit Test:
    `RuntimeFilterMergerTest.merge_from_never_writes_producer_wrapper`
    (merger owns a private copy; a shared wrapper merged again and a
    disabled producer do not touch the producer's wrapper),
    `RuntimeFilterWrapperTest.TestMergeInWithBloomCopiesBloomFilter` (IN +
    BLOOM merge copies the input bloom filter) and `TestClone`, and 7 cases
    in `RuntimeFilterProducerTest` covering mixed local/local-merge targets,
    the merge-overflow path, the IN_OR_BLOOM merge which takes a bloom
    filter, broadcast join with a shared hash table (including an
    early-terminated/disabled instance), an all-disabled mixed case, and the
    non-mixed case. They fail on the pre-fix code (either by aborting on the
    existing `DORIS_CHECK` or by plain assertion failure). No regression
    test was added: the failure is timing-dependent on the publish order
    across pipeline instances of the same fragment, which cannot be driven
    deterministically from SQL.
    - Manual test: No need to test (BE-internal object lifecycle change,
    fully covered by the new unit tests)
    - Behavior changed: Yes — the local merger (and the merge node) now
    merges into a private copy of the first received filter instead of the
    producer's own object, so consumers in local RF mgr never observe a
    concurrently mutated filter. No code depended on the old (racy,
    effectively undefined) behavior.
    - Does this need documentation: No
---
 be/src/exec/runtime_filter/runtime_filter_merger.h |  22 +-
 be/src/exec/runtime_filter/runtime_filter_mgr.cpp  |   6 +-
 .../runtime_filter/runtime_filter_producer.cpp     |  51 ++-
 .../exec/runtime_filter/runtime_filter_wrapper.cpp |  39 +-
 .../exec/runtime_filter/runtime_filter_wrapper.h   |   8 +-
 be/src/exprs/bloom_filter_func.h                   |  34 ++
 be/src/exprs/filter_base.h                         |   1 +
 be/src/exprs/minmax_predicate.h                    |  11 +
 .../runtime_filter/runtime_filter_merger_test.cpp  | 105 +++++
 .../runtime_filter_producer_test.cpp               | 496 +++++++++++++++++++++
 .../runtime_filter/runtime_filter_wrapper_test.cpp | 313 +++++++++++++
 11 files changed, 1072 insertions(+), 14 deletions(-)

diff --git a/be/src/exec/runtime_filter/runtime_filter_merger.h 
b/be/src/exec/runtime_filter/runtime_filter_merger.h
index a7bd3605f71..3e0b465b1f8 100644
--- a/be/src/exec/runtime_filter/runtime_filter_merger.h
+++ b/be/src/exec/runtime_filter/runtime_filter_merger.h
@@ -58,7 +58,17 @@ public:
 
     // If input is a disabled predicate, the final result is a disabled 
predicate.
     // Returns true only for the call that makes the merger ready.
-    Status merge_from(const RuntimeFilter* other, bool* ready) {
+    //
+    // `other_wrapper_exclusively_owned` must be true only when the caller 
guarantees `other`'s
+    // wrapper has no other reader and will not be read again after this call 
(e.g. a producer
+    // whose wrapper is never signaled to a plain local consumer, or an 
RPC-only filter built
+    // solely to carry one merge request). The merger then takes the wrapper 
over directly
+    // instead of paying for a deep copy. It must stay false whenever 
`other`'s wrapper may still
+    // be used by consumers in local RF mgr of the same instance, or is shared 
by all producers of
+    // a broadcast join with a shared hash table: the merger must never write 
such a wrapper, so
+    // it only merges into its own copy.
+    Status merge_from(const RuntimeFilter* other, bool* ready,
+                      bool other_wrapper_exclusively_owned = false) {
         std::unique_lock<std::recursive_mutex> l(_rmtx);
         _received_producer_num++;
         if (_expected_producer_num < _received_producer_num) {
@@ -70,11 +80,13 @@ public:
             _rf_state = State::READY;
         }
         if (_wrapper->get_state() == RuntimeFilterWrapper::State::UNINITED) {
-            _wrapper = other->_wrapper;
-            return Status::OK();
+            if (other_wrapper_exclusively_owned) {
+                _wrapper = other->_wrapper;
+                return Status::OK();
+            }
+            return other->_wrapper->clone(&_wrapper);
         }
-        auto st = _wrapper->merge(other->_wrapper.get());
-        return st;
+        return _wrapper->merge(other->_wrapper.get(), 
other_wrapper_exclusively_owned);
     }
 
     // Only raise the expected producer count. RuntimeFilterMgr may compute the
diff --git a/be/src/exec/runtime_filter/runtime_filter_mgr.cpp 
b/be/src/exec/runtime_filter/runtime_filter_mgr.cpp
index 29912f5c441..468e8851720 100644
--- a/be/src/exec/runtime_filter/runtime_filter_mgr.cpp
+++ b/be/src/exec/runtime_filter/runtime_filter_mgr.cpp
@@ -589,7 +589,11 @@ Status 
RuntimeFilterMergeControllerEntity::merge(std::shared_ptr<QueryContext> q
 
         RETURN_IF_ERROR(tmp_filter->assign(*request, attach_data));
 
-        RETURN_IF_ERROR(cnt_val.merger->merge_from(tmp_filter.get(), 
&is_ready));
+        // `tmp_filter` is created solely to carry this one RPC's filter into 
the merger and is
+        // discarded right after; nothing else reads its wrapper, so the 
merger may take it over
+        // without a copy.
+        RETURN_IF_ERROR(cnt_val.merger->merge_from(tmp_filter.get(), &is_ready,
+                                                   
/*other_wrapper_exclusively_owned=*/true));
 
         cnt_val.arrive_id.insert(UniqueId(request->fragment_instance_id()));
     }
diff --git a/be/src/exec/runtime_filter/runtime_filter_producer.cpp 
b/be/src/exec/runtime_filter/runtime_filter_producer.cpp
index 5a342911627..42d1938b790 100644
--- a/be/src/exec/runtime_filter/runtime_filter_producer.cpp
+++ b/be/src/exec/runtime_filter/runtime_filter_producer.cpp
@@ -26,6 +26,18 @@
 #include "util/brpc_closure.h"
 
 namespace doris {
+namespace {
+// Nothing other than the merger can read the wrapper `merge_from` is about to 
receive: either no
+// plain local consumer of `state` exists at all 
(`_send_to_local_targets(state, this, false)` is
+// then a no-op), or `merger` expects only this one producer, so `merge_from` 
becomes ready on
+// this very call and never merges another producer into the wrapper it adopts 
here, even though
+// a plain local consumer also reads it.
+bool wrapper_has_no_other_reader(RuntimeState* state, int filter_id, 
RuntimeFilterMerger* merger) {
+    return 
state->local_runtime_filter_mgr()->get_consume_filters(filter_id).empty() ||
+           merger->get_expected_producer_num() == 1;
+}
+} // namespace
+
 Status RuntimeFilterProducer::_send_to_remote_targets(RuntimeState* state,
                                                       RuntimeFilter* 
merger_filter) {
     TNetworkAddress addr;
@@ -44,11 +56,21 @@ Status 
RuntimeFilterProducer::_send_to_local_targets(RuntimeState* state, Runtim
     return Status::OK();
 };
 
+// Pre-existing complexity debt from the target/broadcast/remote branching 
below, already over
+// threshold before this change; the ownership refinement above is factored 
out to keep the
+// increase small, but not addressed further here to keep this diff focused.
+// NOLINTNEXTLINE(readability-function-cognitive-complexity)
 Status RuntimeFilterProducer::publish(RuntimeState* state, bool 
build_hash_table) {
     std::unique_lock<std::recursive_mutex> l(_rmtx);
     _check_state({State::READY_TO_PUBLISH});
 
-    auto do_merge = [&]() {
+    // `allow_relaxed_ownership` is only meaningful when 
`other_wrapper_exclusively_owned` is
+    // false: it lets this call refine that decision using the local-merge 
context it fetches
+    // below, instead of the caller guessing it without the context. It must 
stay false for the
+    // remote-target call site, whose `true` already holds unconditionally for 
a different
+    // reason (see its call below), and for the broadcast case, whose 
producers alias one shared
+    // wrapper (see 
`RuntimeFilterProducerTest.publish_mixed_targets_shared_wrapper`).
+    auto do_merge = [&](bool other_wrapper_exclusively_owned, bool 
allow_relaxed_ownership) {
         if (!_need_do_merge(state)) {
             // when global consumer not exist, send_to_local_targets will do 
nothing, so merge rf is useless
             return Status::OK();
@@ -60,8 +82,12 @@ Status RuntimeFilterProducer::publish(RuntimeState* state, 
bool build_hash_table
             // Filter was removed during a recursive CTE stage reset; this 
producer is stale.
             return Status::OK();
         }
+        if (!other_wrapper_exclusively_owned && allow_relaxed_ownership) {
+            other_wrapper_exclusively_owned = wrapper_has_no_other_reader(
+                    state, _wrapper->filter_id(), context->merger.get());
+        }
         bool ready = false;
-        RETURN_IF_ERROR(context->merger->merge_from(this, &ready));
+        RETURN_IF_ERROR(context->merger->merge_from(this, &ready, 
other_wrapper_exclusively_owned));
         if (ready) {
             if (_has_remote_target) {
                 RETURN_IF_ERROR(_send_to_remote_targets(state, 
context->merger.get()));
@@ -75,13 +101,30 @@ Status RuntimeFilterProducer::publish(RuntimeState* state, 
bool build_hash_table
     if (!_has_remote_target) {
         // A runtime filter may have multiple targets and some of those are 
local-merge RF and others are not.
         // So for all runtime filters' producers, `publish` should notify all 
consumers in global RF mgr which manages local-merge RF and local RF mgr which 
manages others.
-        RETURN_IF_ERROR(do_merge());
+        // The merger never writes this wrapper while a plain local consumer 
may still read it
+        // (see `RuntimeFilterMerger::merge_from`), so by default the 
consumers in local RF mgr
+        // can use it right away while the merge of the other producers goes 
on. `do_merge` may
+        // still find no such consumer exists (or this is the only producer) 
and adopt the
+        // wrapper directly. Broadcast producers alias one shared wrapper 
across instances (see
+        // `publish_mixed_targets_shared_wrapper`), so relaxing ownership is 
unsound for them;
+        // `build_bf_by_runtime_size` is excluded too, to keep this call's 
precondition the same
+        // simple "nothing else can reach this wrapper" rule as the 
remote-target call below,
+        // without re-deriving it for a filter whose size-sync step this 
branch never exercises.
+        RETURN_IF_ERROR(do_merge(/*other_wrapper_exclusively_owned=*/false,
+                                 
/*allow_relaxed_ownership=*/!_is_broadcast_join &&
+                                         
!_wrapper->build_bf_by_runtime_size()));
         RETURN_IF_ERROR(_send_to_local_targets(state, this, false));
     } else if (build_hash_table) {
         if (_is_broadcast_join) {
             RETURN_IF_ERROR(_send_to_remote_targets(state, this));
         } else {
-            RETURN_IF_ERROR(do_merge());
+            // This path never hands `_wrapper` to a plain local consumer 
(that only happens in
+            // the `!_has_remote_target` branch above), and the shared-wrapper 
broadcast-join
+            // case is the other arm of this `if`, so no other producer 
aliases it either. The
+            // only remaining reference after this call is the one 
`_wrapper.reset()` below
+            // drops, so the merger may take `_wrapper` over directly instead 
of cloning it.
+            RETURN_IF_ERROR(do_merge(/*other_wrapper_exclusively_owned=*/true,
+                                     /*allow_relaxed_ownership=*/false));
         }
     } else {
         if (!_is_broadcast_join) {
diff --git a/be/src/exec/runtime_filter/runtime_filter_wrapper.cpp 
b/be/src/exec/runtime_filter/runtime_filter_wrapper.cpp
index cd5fc2c04bd..66fa692b65a 100644
--- a/be/src/exec/runtime_filter/runtime_filter_wrapper.cpp
+++ b/be/src/exec/runtime_filter/runtime_filter_wrapper.cpp
@@ -125,7 +125,11 @@ bool RuntimeFilterWrapper::build_bf_by_runtime_size() 
const {
     return _bloom_filter_func ? _bloom_filter_func->build_bf_by_runtime_size() 
: false;
 }
 
-Status RuntimeFilterWrapper::merge(const RuntimeFilterWrapper* other) {
+// Pre-existing size/complexity debt from the per-filter-type switch below, 
already over
+// threshold before this change; not addressed here to keep this diff focused.
+// 
NOLINTNEXTLINE(readability-function-size,readability-function-cognitive-complexity)
+Status RuntimeFilterWrapper::merge(const RuntimeFilterWrapper* other,
+                                   bool other_exclusively_owned) {
     DORIS_CHECK(!_bucket_prune_hashes_started.load());
     if (_state == State::DISABLED) {
         return Status::OK();
@@ -189,10 +193,16 @@ Status RuntimeFilterWrapper::merge(const 
RuntimeFilterWrapper* other) {
                     
RETURN_IF_ERROR(_bloom_filter_func->init_with_fixed_length(0));
                     RETURN_IF_ERROR(_change_to_bloom_filter());
                 }
-            } else {
-                // case1&case2: use input bf directly and insert hybrid set 
data into bf
+            } else if (other_exclusively_owned) {
+                // `other` has no other reader, so its bf can be taken over 
directly instead of
+                // cloned before inserting the hybrid set data into it.
                 _bloom_filter_func = other->_bloom_filter_func;
                 RETURN_IF_ERROR(_change_to_bloom_filter());
+            } else {
+                // case1&case2: use a copy of input bf and insert hybrid set 
data into it. `other`
+                // may still be used by its own consumers, so it must not be 
written.
+                
RETURN_IF_ERROR(other->_bloom_filter_func->clone(&_bloom_filter_func, true));
+                RETURN_IF_ERROR(_change_to_bloom_filter());
             }
         } else {
             if (other_filter_type == RuntimeFilterType::IN_FILTER) {
@@ -212,6 +222,29 @@ Status RuntimeFilterWrapper::merge(const 
RuntimeFilterWrapper* other) {
     return Status::OK();
 }
 
+Status RuntimeFilterWrapper::clone(std::shared_ptr<RuntimeFilterWrapper>* res) 
const {
+    auto cloned = std::make_shared<RuntimeFilterWrapper>(_column_return_type, 
_filter_type,
+                                                         _filter_id, 
_state.load(), _max_in_num);
+    cloned->_disable_always_true_logic = _disable_always_true_logic;
+    cloned->_reason.update(_reason.status());
+    if (_hybrid_set) {
+        cloned->_hybrid_set.reset(create_set(_column_return_type, 
_hybrid_set->null_aware()));
+        cloned->_hybrid_set->insert(_hybrid_set.get());
+    }
+    if (_minmax_func) {
+        cloned->_minmax_func.reset(_minmax_func->clone());
+    }
+    if (_bloom_filter_func) {
+        // An IN_OR_BLOOM filter which is still an IN filter never reads its 
bloom filter: a
+        // later merge which changes the copy to a bloom filter either 
re-initializes the bloom
+        // filter or takes a copy of the input one first. So do not copy the 
unused bloom filter.
+        RETURN_IF_ERROR(_bloom_filter_func->clone(
+                &cloned->_bloom_filter_func, get_real_type() == 
RuntimeFilterType::BLOOM_FILTER));
+    }
+    *res = std::move(cloned);
+    return Status::OK();
+}
+
 Status RuntimeFilterWrapper::_assign(const PInFilter& in_filter, bool 
contain_null) {
     if (contain_null) {
         _hybrid_set->insert((const void*)nullptr);
diff --git a/be/src/exec/runtime_filter/runtime_filter_wrapper.h 
b/be/src/exec/runtime_filter/runtime_filter_wrapper.h
index 7fb770e8855..8aa303733d0 100644
--- a/be/src/exec/runtime_filter/runtime_filter_wrapper.h
+++ b/be/src/exec/runtime_filter/runtime_filter_wrapper.h
@@ -57,7 +57,13 @@ public:
 
     Status init(const size_t runtime_size);
     Status insert(const ColumnPtr& column, size_t start);
-    Status merge(const RuntimeFilterWrapper* wrapper);
+    // `other_exclusively_owned` must be true only when `other` has no other 
reader and will not
+    // be read again after this call: a transitional BLOOM directory taken 
from it is then moved
+    // in directly instead of cloned. See `RuntimeFilterMerger::merge_from`.
+    Status merge(const RuntimeFilterWrapper* other, bool 
other_exclusively_owned = false);
+    // Deep copy of the filter. The result shares no mutable data with this 
wrapper, so the
+    // merger can keep merging into the copy while this one is already used by 
consumers.
+    Status clone(std::shared_ptr<RuntimeFilterWrapper>* res) const;
     template <class T>
     Status assign(const T& request, butil::IOBufAsZeroCopyInputStream* data);
 
diff --git a/be/src/exprs/bloom_filter_func.h b/be/src/exprs/bloom_filter_func.h
index 7e5eaeecb7e..ba4bf8ebb77 100644
--- a/be/src/exprs/bloom_filter_func.h
+++ b/be/src/exprs/bloom_filter_func.h
@@ -132,6 +132,12 @@ public:
         _bloom_filter = other_func->_bloom_filter;
     }
 
+    // Deep copy. The returned filter owns a separately allocated bloom 
filter. Only the
+    // parameters are copied if `copy_bloom_filter` is false, e.g. the filter 
is still used as
+    // an IN filter, so its bloom filter is never read.
+    virtual Status clone(std::shared_ptr<BloomFilterFuncBase>* res,
+                         bool copy_bloom_filter) const = 0;
+
     virtual void insert_set(std::shared_ptr<HybridSetBase> set) = 0;
 
     virtual void insert_fixed_len(const ColumnPtr& column, size_t start) = 0;
@@ -166,6 +172,26 @@ private:
     }
 
 protected:
+    Status _deep_copy_from(const BloomFilterFuncBase& other, bool 
copy_bloom_filter) {
+        _bloom_filter_length = other._bloom_filter_length;
+        _runtime_bloom_filter_min_size = other._runtime_bloom_filter_min_size;
+        _runtime_bloom_filter_max_size = other._runtime_bloom_filter_max_size;
+        _build_bf_by_runtime_size = other._build_bf_by_runtime_size;
+        _bloom_filter_size_calculated_by_ndv = 
other._bloom_filter_size_calculated_by_ndv;
+        if (!copy_bloom_filter) {
+            return Status::OK();
+        }
+        if (other._bloom_filter == nullptr) {
+            // `other` is not initialized yet, e.g. the producer is disabled 
before it is built.
+            return Status::OK();
+        }
+        _bloom_filter_alloced = other._bloom_filter_alloced;
+        _bloom_filter.reset(BloomFilterAdaptor::create(_null_aware));
+        RETURN_IF_ERROR(_bloom_filter->init(_bloom_filter_alloced));
+        _bloom_filter->set_contain_null(other.contain_null());
+        return _bloom_filter->merge(other._bloom_filter.get());
+    }
+
     int64_t _bloom_filter_alloced = 0;
     std::shared_ptr<BloomFilterAdaptor> _bloom_filter;
     int64_t _bloom_filter_length;
@@ -179,6 +205,14 @@ template <PrimitiveType type>
 class BloomFilterFunc final : public BloomFilterFuncBase {
 public:
     BloomFilterFunc(bool null_aware) : BloomFilterFuncBase(null_aware) {}
+
+    Status clone(std::shared_ptr<BloomFilterFuncBase>* res, bool 
copy_bloom_filter) const override {
+        auto cloned = std::make_shared<BloomFilterFunc<type>>(_null_aware);
+        RETURN_IF_ERROR(cloned->_deep_copy_from(*this, copy_bloom_filter));
+        *res = std::move(cloned);
+        return Status::OK();
+    }
+
     void insert_set(std::shared_ptr<HybridSetBase> set) override {
         OpV2::insert_set(*_bloom_filter, set);
         _bloom_filter->set_contain_null(set->contain_null());
diff --git a/be/src/exprs/filter_base.h b/be/src/exprs/filter_base.h
index 25778059886..ca808b310ca 100644
--- a/be/src/exprs/filter_base.h
+++ b/be/src/exprs/filter_base.h
@@ -25,6 +25,7 @@ class FilterBase {
 public:
     FilterBase(bool null_aware) : _null_aware(null_aware) {}
     bool contain_null() const { return _null_aware && _contain_null; }
+    bool null_aware() const { return _null_aware; }
 
     void set_contain_null(bool contain_null) { _contain_null |= contain_null; }
 
diff --git a/be/src/exprs/minmax_predicate.h b/be/src/exprs/minmax_predicate.h
index b0f1b27e7a8..77fe7ec6bc5 100644
--- a/be/src/exprs/minmax_predicate.h
+++ b/be/src/exprs/minmax_predicate.h
@@ -37,6 +37,8 @@ public:
     virtual Status assign(void* min_data, void* max_data) = 0;
     // merge from other minmax_func
     virtual Status merge(MinMaxFuncBase* minmax_func) = 0;
+    // Deep copy. The returned object shares nothing with this one.
+    virtual MinMaxFuncBase* clone() const = 0;
     virtual ~MinMaxFuncBase() = default;
 
     virtual void to_pb(PMinMaxFilter* filter) = 0;
@@ -87,6 +89,15 @@ public:
         return Status::OK();
     }
 
+    MinMaxFuncBase* clone() const override {
+        auto* cloned = new MinMaxNumFunc(_null_aware);
+        cloned->_max = _max;
+        cloned->_min = _min;
+        cloned->_min_value_set = _min_value_set;
+        cloned->_contain_null = _contain_null;
+        return cloned;
+    }
+
     void* get_max() override { return &_max; }
 
     void* get_min() override { return &_min; }
diff --git a/be/test/exec/runtime_filter/runtime_filter_merger_test.cpp 
b/be/test/exec/runtime_filter/runtime_filter_merger_test.cpp
index 3d0bb701c8b..5823d39797b 100644
--- a/be/test/exec/runtime_filter/runtime_filter_merger_test.cpp
+++ b/be/test/exec/runtime_filter/runtime_filter_merger_test.cpp
@@ -20,8 +20,11 @@
 #include <glog/logging.h>
 #include <gtest/gtest.h>
 
+#include "core/data_type/data_type_number.h"
 #include "exec/runtime_filter/runtime_filter_producer.h"
 #include "exec/runtime_filter/runtime_filter_test_utils.h"
+#include "exprs/hybrid_set.h"
+#include "testutil/column_helper.h"
 
 namespace doris {
 
@@ -95,6 +98,108 @@ public:
     }
 };
 
+// The merger merges into a private copy of the first wrapper and never writes 
a producer's
+// wrapper, which may be shared with consumers in local RF mgr or with other 
producers.
+TEST_F(RuntimeFilterMergerTest, merge_from_never_writes_producer_wrapper) {
+    std::shared_ptr<RuntimeFilterMerger> merger;
+    auto desc = TRuntimeFilterDescBuilder().build();
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(RuntimeFilterMerger::create(_query_ctx.get(), 
&desc, &merger));
+    merger->increase_expected_producer_num(3);
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    auto wrapper = producer->wrapper();
+    auto hashes = 
wrapper->get_or_compute_bucket_prune_hashes(std::make_shared<DataTypeInt32>());
+    ASSERT_EQ(hashes->size(), 1);
+
+    bool ready = false;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(merger->merge_from(producer.get(), 
&ready));
+    ASSERT_FALSE(ready);
+    ASSERT_NE(merger->_wrapper, wrapper);
+    ASSERT_NE(merger->_wrapper->hybrid_set(), wrapper->hybrid_set());
+    ASSERT_EQ(merger->_wrapper->_state, RuntimeFilterWrapper::State::READY);
+    ASSERT_EQ(merger->_wrapper->hybrid_set()->size(), 1);
+    ASSERT_FALSE(merger->_wrapper->_bucket_prune_hashes_started.load());
+
+    // The same wrapper published by another producer of a broadcast join 
changes nothing.
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+    producer2->set_wrapper(wrapper);
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(merger->merge_from(producer2.get(), 
&ready));
+    ASSERT_FALSE(ready);
+    ASSERT_EQ(merger->_wrapper->hybrid_set()->size(), 1);
+
+    // A disabled producer (e.g. of an early terminated instance) disables the 
merger's copy only.
+    auto terminated_mgr = std::make_unique<RuntimeFilterMgr>(false);
+    auto terminated_state =
+            RuntimeState::create_unique(TUniqueId(), 0, _query_options, 
_query_ctx->query_globals,
+                                        ExecEnv::GetInstance(), 
_query_ctx.get());
+    terminated_state->set_runtime_filter_mgr(terminated_mgr.get());
+    terminated_state->set_desc_tbl(&_tbl);
+    std::shared_ptr<RuntimeFilterProducer> producer3;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            terminated_state->register_producer_runtime_filter(desc, 
&producer3));
+    
producer3->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::DISABLED,
+                                                      "skip all rf process");
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(merger->merge_from(producer3.get(), 
&ready));
+    ASSERT_TRUE(ready);
+    ASSERT_EQ(merger->_wrapper->_state, RuntimeFilterWrapper::State::DISABLED);
+    ASSERT_NE(merger->_wrapper->_reason.status().msg().find("skip all rf 
process"),
+              std::string::npos);
+
+    int32_t five = 5;
+    ASSERT_EQ(wrapper->_state, RuntimeFilterWrapper::State::READY);
+    ASSERT_EQ(wrapper->hybrid_set()->size(), 1);
+    ASSERT_TRUE(wrapper->hybrid_set()->find(&five));
+    
ASSERT_EQ(wrapper->get_or_compute_bucket_prune_hashes(std::make_shared<DataTypeInt32>()),
+              hashes);
+}
+
+// A caller who guarantees `other`'s wrapper has no other reader (e.g. an 
RPC-only filter built
+// solely for this merge, see `RuntimeFilterMergeControllerEntity::merge`) may 
let the merger take
+// it over directly instead of paying for a deep copy.
+TEST_F(RuntimeFilterMergerTest, merge_from_exclusively_owned_takes_ownership) {
+    std::shared_ptr<RuntimeFilterMerger> merger;
+    auto desc = TRuntimeFilterDescBuilder().build();
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(RuntimeFilterMerger::create(_query_ctx.get(), 
&desc, &merger));
+    merger->increase_expected_producer_num(2);
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    auto wrapper = producer->wrapper();
+
+    bool ready = false;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            merger->merge_from(producer.get(), &ready, 
/*other_wrapper_exclusively_owned=*/true));
+    ASSERT_FALSE(ready);
+    // No clone: the merger took over the exclusively owned wrapper directly.
+    ASSERT_EQ(merger->_wrapper, wrapper);
+
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer2->insert(ColumnHelper::create_column<DataTypeInt32>({6}), 
0));
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            merger->merge_from(producer2.get(), &ready, 
/*other_wrapper_exclusively_owned=*/true));
+    ASSERT_TRUE(ready);
+    ASSERT_EQ(merger->_wrapper->hybrid_set()->size(), 2);
+}
+
 TEST_F(RuntimeFilterMergerTest, basic) {
     test_merge_from(RuntimeFilterWrapper::State::READY, 
RuntimeFilterWrapper::State::READY,
                     RuntimeFilterWrapper::State::READY, 
RuntimeFilterWrapper::State::READY);
diff --git a/be/test/exec/runtime_filter/runtime_filter_producer_test.cpp 
b/be/test/exec/runtime_filter/runtime_filter_producer_test.cpp
index aa0f93e95ed..42062eec3e4 100644
--- a/be/test/exec/runtime_filter/runtime_filter_producer_test.cpp
+++ b/be/test/exec/runtime_filter/runtime_filter_producer_test.cpp
@@ -20,8 +20,17 @@
 #include <glog/logging.h>
 #include <gtest/gtest.h>
 
+#include <algorithm>
+#include <numeric>
+#include <string>
+
+#include "core/data_type/data_type_number.h"
 #include "exec/runtime_filter/runtime_filter_consumer.h"
+#include "exec/runtime_filter/runtime_filter_merger.h"
 #include "exec/runtime_filter/runtime_filter_test_utils.h"
+#include "exprs/bloom_filter_func.h"
+#include "exprs/hybrid_set.h"
+#include "testutil/column_helper.h"
 
 namespace doris {
 
@@ -175,6 +184,493 @@ TEST_F(RuntimeFilterProducerTest, set_disable) {
     ASSERT_EQ(consumer->_wrapper->_state, 
RuntimeFilterWrapper::State::DISABLED);
 }
 
+// A runtime filter whose consumers are partly in local RF mgr and partly in 
global RF mgr.
+TEST_F(RuntimeFilterProducerTest, 
publish_mixed_targets_local_consumer_gets_private_wrapper) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(false)
+                        .add_planId_to_target_expr(0)
+                        .add_planId_to_target_expr(1)
+                        .build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer));
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer2));
+    std::shared_ptr<RuntimeFilterConsumer> merge_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, true, 
1, &merge_consumer));
+
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer2->insert(ColumnHelper::create_column<DataTypeInt32>({6}), 
0));
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    ASSERT_EQ(local_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(merge_consumer->_rf_state, 
RuntimeFilterConsumer::State::NOT_READY);
+
+    // The local consumer builds its bucket prune cache before the merger 
receives producer2.
+    std::vector<RuntimeFilterExprPtr> exprs;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(local_consumer->acquire_expr(exprs));
+    ASSERT_EQ(exprs.size(), 1);
+    auto hashes = 
exprs[0]->get_bucket_prune_hashes(std::make_shared<DataTypeInt32>());
+    ASSERT_EQ(hashes->size(), 1);
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->publish(_runtime_states[1].get(), 
true));
+    ASSERT_EQ(merge_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(local_consumer2->_rf_state, RuntimeFilterConsumer::State::READY);
+
+    int32_t five = 5;
+    int32_t six = 6;
+    auto merged_set = merge_consumer->_wrapper->hybrid_set();
+    ASSERT_EQ(merged_set->size(), 2);
+    ASSERT_TRUE(merged_set->find(&five));
+    ASSERT_TRUE(merged_set->find(&six));
+
+    auto local_set = local_consumer->_wrapper->hybrid_set();
+    ASSERT_EQ(local_set->size(), 1);
+    ASSERT_TRUE(local_set->find(&five));
+    ASSERT_FALSE(local_set->find(&six));
+    auto local_set2 = local_consumer2->_wrapper->hybrid_set();
+    ASSERT_EQ(local_set2->size(), 1);
+    ASSERT_TRUE(local_set2->find(&six));
+    ASSERT_FALSE(local_set2->find(&five));
+
+    ASSERT_NE(local_consumer->_wrapper, merge_consumer->_wrapper);
+    ASSERT_NE(local_consumer2->_wrapper, merge_consumer->_wrapper);
+    ASSERT_NE(local_consumer->_wrapper, local_consumer2->_wrapper);
+    ASSERT_NE(local_set, merged_set);
+    ASSERT_NE(local_set2, merged_set);
+    ASSERT_NE(local_consumer->_wrapper->bloom_filter_func(),
+              merge_consumer->_wrapper->bloom_filter_func());
+
+    
ASSERT_EQ(exprs[0]->get_bucket_prune_hashes(std::make_shared<DataTypeInt32>()), 
hashes);
+    ASSERT_EQ(hashes->size(), 1);
+
+    std::vector<RuntimeFilterExprPtr> merged_exprs;
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(merge_consumer->acquire_expr(merged_exprs));
+    ASSERT_EQ(merged_exprs.size(), 1);
+    
ASSERT_EQ(merged_exprs[0]->get_bucket_prune_hashes(std::make_shared<DataTypeInt32>())->size(),
+              2);
+}
+
+TEST_F(RuntimeFilterProducerTest, 
publish_mixed_targets_merge_overflow_keeps_local_filter) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(false)
+                        .set_type(TRuntimeFilterType::IN)
+                        .add_planId_to_target_expr(0)
+                        .add_planId_to_target_expr(1)
+                        .build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+
+    // Only the first instance has a consumer in local RF mgr.
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer));
+    std::shared_ptr<RuntimeFilterConsumer> merge_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, true, 
1, &merge_consumer));
+
+    // Each producer is within runtime_filter_max_in_num, but the merged one 
is not.
+    std::vector<int32_t> data(600);
+    std::iota(data.begin(), data.end(), 0);
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(data.size()));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>(data), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    std::iota(data.begin(), data.end(), 600);
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->init(data.size()));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            
producer2->insert(ColumnHelper::create_column<DataTypeInt32>(data), 0));
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->publish(_runtime_states[1].get(), 
true));
+
+    ASSERT_EQ(merge_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(merge_consumer->_wrapper->get_state(), 
RuntimeFilterWrapper::State::DISABLED);
+
+    ASSERT_EQ(local_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(local_consumer->_wrapper->get_state(), 
RuntimeFilterWrapper::State::READY);
+    auto local_set = local_consumer->_wrapper->hybrid_set();
+    ASSERT_EQ(local_set->size(), 600);
+    int32_t value = 0;
+    ASSERT_TRUE(local_set->find(&value));
+    value = 599;
+    ASSERT_TRUE(local_set->find(&value));
+    value = 600;
+    ASSERT_FALSE(local_set->find(&value));
+}
+
+// Broadcast join producers which share one hash table also share one wrapper.
+TEST_F(RuntimeFilterProducerTest, publish_mixed_targets_shared_wrapper) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(true)
+                        .add_planId_to_target_expr(0)
+                        .add_planId_to_target_expr(1)
+                        .build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer));
+    std::shared_ptr<RuntimeFilterConsumer> merge_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, true, 
1, &merge_consumer));
+
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    producer2->set_wrapper(producer->wrapper());
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    std::vector<RuntimeFilterExprPtr> exprs;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(local_consumer->acquire_expr(exprs));
+    ASSERT_EQ(exprs.size(), 1);
+    
ASSERT_EQ(exprs[0]->get_bucket_prune_hashes(std::make_shared<DataTypeInt32>())->size(),
 1);
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->publish(_runtime_states[1].get(), 
true));
+    ASSERT_EQ(merge_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    auto merged_set = merge_consumer->_wrapper->hybrid_set();
+    ASSERT_EQ(merged_set->size(), 1);
+    int32_t five = 5;
+    ASSERT_TRUE(merged_set->find(&five));
+    ASSERT_NE(local_consumer->_wrapper, merge_consumer->_wrapper);
+}
+
+// An early terminated instance of a broadcast join publishes its own disabled 
wrapper, which must
+// not disable the shared wrapper in place: the local consumers of other 
instances still use it.
+TEST_F(RuntimeFilterProducerTest, 
publish_mixed_targets_shared_wrapper_with_disabled_instance) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(true)
+                        .add_planId_to_target_expr(0)
+                        .add_planId_to_target_expr(1)
+                        .build();
+
+    auto terminated_mgr = std::make_unique<RuntimeFilterMgr>(false);
+    auto terminated_state =
+            RuntimeState::create_unique(TUniqueId(), 0, _query_options, 
_query_ctx->query_globals,
+                                        ExecEnv::GetInstance(), 
_query_ctx.get());
+    terminated_state->set_runtime_filter_mgr(terminated_mgr.get());
+    terminated_state->set_desc_tbl(&_tbl);
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+    std::shared_ptr<RuntimeFilterProducer> terminated_producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            terminated_state->register_producer_runtime_filter(desc, 
&terminated_producer));
+
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer));
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer2));
+    std::shared_ptr<RuntimeFilterConsumer> merge_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, true, 
1, &merge_consumer));
+
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    auto shared_wrapper = producer->wrapper();
+    producer2->set_wrapper(shared_wrapper);
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    terminated_producer->set_wrapper_state_and_ready_to_publish(
+            RuntimeFilterWrapper::State::DISABLED, "skip all rf process");
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(terminated_producer->publish(terminated_state.get(),
 true));
+    int32_t five = 5;
+    ASSERT_EQ(shared_wrapper->get_state(), RuntimeFilterWrapper::State::READY);
+    ASSERT_EQ(shared_wrapper->hybrid_set()->size(), 1);
+    ASSERT_TRUE(shared_wrapper->hybrid_set()->find(&five));
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->publish(_runtime_states[1].get(), 
true));
+    ASSERT_EQ(shared_wrapper->get_state(), RuntimeFilterWrapper::State::READY);
+    ASSERT_EQ(shared_wrapper->hybrid_set()->size(), 1);
+    for (const auto& consumer : {local_consumer, local_consumer2}) {
+        ASSERT_EQ(consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+        ASSERT_EQ(consumer->_wrapper, shared_wrapper);
+        ASSERT_EQ(consumer->_wrapper->get_state(), 
RuntimeFilterWrapper::State::READY);
+        ASSERT_EQ(consumer->_wrapper->hybrid_set()->size(), 1);
+        ASSERT_TRUE(consumer->_wrapper->hybrid_set()->find(&five));
+    }
+    ASSERT_EQ(merge_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_NE(merge_consumer->_wrapper, shared_wrapper);
+    ASSERT_EQ(merge_consumer->_wrapper->get_state(), 
RuntimeFilterWrapper::State::DISABLED);
+    ASSERT_NE(merge_consumer->_wrapper->_reason.status().msg().find("skip all 
rf process"),
+              std::string::npos);
+}
+
+TEST_F(RuntimeFilterProducerTest, publish_mixed_targets_disabled) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(false)
+                        .add_planId_to_target_expr(0)
+                        .add_planId_to_target_expr(1)
+                        .build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer));
+    std::shared_ptr<RuntimeFilterConsumer> merge_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, true, 
1, &merge_consumer));
+
+    // Producers are disabled before the filters are built.
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::DISABLED);
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::DISABLED);
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->publish(_runtime_states[1].get(), 
true));
+
+    ASSERT_EQ(local_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(local_consumer->_wrapper->get_state(), 
RuntimeFilterWrapper::State::DISABLED);
+    ASSERT_EQ(merge_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(merge_consumer->_wrapper->get_state(), 
RuntimeFilterWrapper::State::DISABLED);
+    ASSERT_NE(local_consumer->_wrapper, merge_consumer->_wrapper);
+}
+
+// A runtime filter whose only targets are local-merge (global RF mgr) 
consumers never hands its
+// wrapper to a plain local consumer of either instance, so once it reaches 
the merger nothing
+// else reads it: the merger may adopt it directly instead of cloning it.
+TEST_F(RuntimeFilterProducerTest, 
publish_local_merge_only_takes_ownership_without_local_consumer) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(false)
+                        .add_planId_to_target_expr(0)
+                        .build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+
+    std::shared_ptr<RuntimeFilterConsumer> consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, true, 
0, &consumer));
+
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer2->insert(ColumnHelper::create_column<DataTypeInt32>({6}), 
0));
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+
+    auto wrapper = producer->wrapper();
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    std::shared_ptr<LocalMergeContext> context;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            
_query_ctx->runtime_filter_mgr()->get_local_merge_context(desc.filter_id, 0, 
&context));
+    // The merger adopted the first producer's wrapper directly instead of 
cloning it: no plain
+    // local consumer of this instance exists to be affected.
+    ASSERT_EQ(context->merger->_wrapper, wrapper);
+    ASSERT_EQ(context->merger->_wrapper->hybrid_set()->size(), 1);
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->publish(_runtime_states[1].get(), 
true));
+    ASSERT_EQ(consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(consumer->_wrapper, context->merger->_wrapper);
+    ASSERT_EQ(consumer->_wrapper->hybrid_set()->size(), 2);
+    int32_t five = 5;
+    int32_t six = 6;
+    ASSERT_TRUE(wrapper->hybrid_set()->find(&five));
+    ASSERT_TRUE(wrapper->hybrid_set()->find(&six));
+}
+
+// A non-broadcast, non-runtime-sized filter with exactly one expected 
producer becomes ready on
+// that producer's only `merge_from` call and is never merged into again, so 
the merger may adopt
+// the producer's wrapper directly even though a plain local consumer of the 
same instance also
+// reads it.
+TEST_F(RuntimeFilterProducerTest, 
publish_single_producer_with_local_consumer_takes_ownership) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(false)
+                        .add_planId_to_target_expr(0)
+                        .add_planId_to_target_expr(1)
+                        .build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer));
+    std::shared_ptr<RuntimeFilterConsumer> merge_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_consumer_runtime_filter(desc, true, 
1, &merge_consumer));
+
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+
+    auto wrapper = producer->wrapper();
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+
+    ASSERT_EQ(local_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(local_consumer->_wrapper, wrapper);
+    ASSERT_EQ(merge_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    // The merger adopted the single producer's wrapper directly: the same 
object the plain local
+    // consumer also received, because the merger never merges another 
producer into it.
+    ASSERT_EQ(merge_consumer->_wrapper, wrapper);
+    int32_t five = 5;
+    ASSERT_TRUE(wrapper->hybrid_set()->find(&five));
+}
+
+// A non-broadcast filter with only remote targets never hands its wrapper to 
a plain local
+// consumer: that only happens in the `!_has_remote_target` branch of 
`publish()`. Once such a
+// producer's wrapper reaches the local merger, no other reader remains, so 
the merger may take
+// it over directly instead of cloning it.
+TEST_F(RuntimeFilterProducerTest, publish_remote_target_merge_takes_ownership) 
{
+    auto desc = 
TRuntimeFilterDescBuilder().set_mode(false).set_is_broadcast_join(false).build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+
+    auto wrapper = producer->wrapper();
+    // Only the first of the two expected producers has published, so the 
merger is not ready
+    // yet and publish() never reaches `_send_to_remote_targets()`.
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    ASSERT_EQ(producer->_wrapper, nullptr);
+
+    std::shared_ptr<LocalMergeContext> context;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            
_query_ctx->runtime_filter_mgr()->get_local_merge_context(desc.filter_id, 0, 
&context));
+    // The merger adopted the producer's wrapper directly instead of cloning 
it.
+    ASSERT_EQ(context->merger->_wrapper, wrapper);
+    ASSERT_EQ(wrapper->hybrid_set()->size(), 1);
+    int32_t five = 5;
+    ASSERT_TRUE(wrapper->hybrid_set()->find(&five));
+}
+
+// An IN_OR_BLOOM merger which is still an IN filter takes the bloom filter of 
a producer which
+// already changed to a bloom filter. It must take a copy, the producer's own 
local consumers are
+// probing the original.
+TEST_F(RuntimeFilterProducerTest, 
publish_mixed_targets_in_or_bloom_merge_copies_bloom_filter) {
+    auto desc = TRuntimeFilterDescBuilder()
+                        .set_build_bf_by_runtime_size(false)
+                        .set_is_broadcast_join(false)
+                        .add_planId_to_target_expr(0)
+                        .add_planId_to_target_expr(1)
+                        .build();
+
+    std::shared_ptr<RuntimeFilterProducer> producer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[0]->register_producer_runtime_filter(desc, 
&producer));
+    std::shared_ptr<RuntimeFilterProducer> producer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_producer_runtime_filter(desc, 
&producer2));
+
+    // Only the second instance has a consumer in local RF mgr.
+    std::shared_ptr<RuntimeFilterConsumer> local_consumer2;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, false, 
0, &local_consumer2));
+    std::shared_ptr<RuntimeFilterConsumer> merge_consumer;
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            _runtime_states[1]->register_consumer_runtime_filter(desc, true, 
1, &merge_consumer));
+
+    // The first producer stays an IN filter, the second one exceeds 
runtime_filter_max_in_num
+    // and changes to a bloom filter.
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->init(1));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer->insert(ColumnHelper::create_column<DataTypeInt32>({5}), 
0));
+    
producer->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    ASSERT_EQ(producer->wrapper()->get_real_type(), 
RuntimeFilterType::IN_FILTER);
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->init(2000));
+    FAIL_IF_ERROR_OR_CATCH_EXCEPTION(
+            producer2->insert(ColumnHelper::create_column<DataTypeInt32>({6, 
7}), 0));
+    
producer2->set_wrapper_state_and_ready_to_publish(RuntimeFilterWrapper::State::READY);
+    auto bloom_wrapper = producer2->wrapper();
+    ASSERT_EQ(bloom_wrapper->get_real_type(), RuntimeFilterType::BLOOM_FILTER);
+    char* bloom_data = nullptr;
+    int bloom_len = 0;
+    bloom_wrapper->bloom_filter_func()->get_data(&bloom_data, &bloom_len);
+    ASSERT_GT(bloom_len, 0);
+    const std::string snapshot(bloom_data, bloom_len);
+
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer->publish(_runtime_states[0].get(), 
true));
+    
FAIL_IF_ERROR_OR_CATCH_EXCEPTION(producer2->publish(_runtime_states[1].get(), 
true));
+
+    ASSERT_EQ(local_consumer2->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(local_consumer2->_wrapper, bloom_wrapper);
+    ASSERT_EQ(merge_consumer->_rf_state, RuntimeFilterConsumer::State::READY);
+    ASSERT_EQ(merge_consumer->_wrapper->get_state(), 
RuntimeFilterWrapper::State::READY);
+    ASSERT_EQ(merge_consumer->_wrapper->get_real_type(), 
RuntimeFilterType::BLOOM_FILTER);
+    ASSERT_NE(merge_consumer->_wrapper->bloom_filter_func(), 
bloom_wrapper->bloom_filter_func());
+
+    // The merged filter contains the values of both producers.
+    std::vector<uint8_t> found(3);
+    merge_consumer->_wrapper->bloom_filter_func()->find_fixed_len(
+            ColumnHelper::create_column<DataTypeInt32>({5, 6, 7}), 
found.data());
+    ASSERT_TRUE(std::all_of(found.begin(), found.end(), [](uint8_t i) -> bool 
{ return i; }));
+
+    // The bloom filter of the second producer is not written by the merge.
+    char* merged_data = nullptr;
+    int merged_len = 0;
+    merge_consumer->_wrapper->bloom_filter_func()->get_data(&merged_data, 
&merged_len);
+    ASSERT_NE(merged_data, bloom_data);
+    ASSERT_EQ(bloom_len, merged_len);
+    ASSERT_EQ(snapshot, std::string(bloom_data, bloom_len));
+    ASSERT_NE(snapshot, std::string(merged_data, merged_len));
+}
+
 TEST_F(RuntimeFilterProducerTest, publish_release_wrapper) {
     auto desc = TRuntimeFilterDescBuilder()
                         .set_build_bf_by_runtime_size(false)
diff --git a/be/test/exec/runtime_filter/runtime_filter_wrapper_test.cpp 
b/be/test/exec/runtime_filter/runtime_filter_wrapper_test.cpp
index 9774ce2f2f1..b4c1978826b 100644
--- a/be/test/exec/runtime_filter/runtime_filter_wrapper_test.cpp
+++ b/be/test/exec/runtime_filter/runtime_filter_wrapper_test.cpp
@@ -22,6 +22,7 @@
 
 #include "core/data_type/data_type_date.h"
 #include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_string.h"
 #include "exprs/bloom_filter_func.h"
 #include "exprs/hybrid_set.h"
 #include "exprs/minmax_predicate.h"
@@ -1210,6 +1211,318 @@ TEST_F(RuntimeFilterWrapperTest, TestInOrBloom) {
     EXPECT_EQ(wrapper->contain_null(), false);
 }
 
+// Merging a bloom filter into an IN_OR_BLOOM filter which is still an IN 
filter takes a copy of
+// the input bloom filter, the input may still be probed by its own consumers.
+TEST_F(RuntimeFilterWrapperTest, TestMergeInWithBloomCopiesBloomFilter) {
+    using DataType = DataTypeInt32;
+    auto make_params = [](int32_t max_in_num) {
+        return RuntimeFilterParams {.filter_id = 0,
+                                    .filter_type = 
RuntimeFilterType::IN_OR_BLOOM_FILTER,
+                                    .column_return_type = 
PrimitiveType::TYPE_INT,
+                                    .null_aware = false,
+                                    .max_in_num = max_in_num,
+                                    .runtime_bloom_filter_min_size = 64,
+                                    .runtime_bloom_filter_max_size = 128,
+                                    .bloom_filter_size = 64,
+                                    .build_bf_by_runtime_size = false,
+                                    .bloom_filter_size_calculated_by_ndv = 
false};
+    };
+    auto in_params = make_params(18);
+    auto wrapper = std::make_shared<RuntimeFilterWrapper>(&in_params);
+    ASSERT_TRUE(wrapper->init(16).ok());
+    ASSERT_EQ(wrapper->get_real_type(), RuntimeFilterType::IN_FILTER);
+    std::vector<int> data_vector(10);
+    std::iota(data_vector.begin(), data_vector.end(), 0);
+    
ASSERT_TRUE(wrapper->insert(ColumnHelper::create_column<DataType>(data_vector), 
0).ok());
+
+    auto bloom_params = make_params(8);
+    auto bloom_wrapper = std::make_shared<RuntimeFilterWrapper>(&bloom_params);
+    ASSERT_TRUE(bloom_wrapper->init(16).ok());
+    ASSERT_EQ(bloom_wrapper->get_real_type(), RuntimeFilterType::BLOOM_FILTER);
+    std::vector<int> bloom_data_vector(10);
+    std::iota(bloom_data_vector.begin(), bloom_data_vector.end(), 10);
+    
ASSERT_TRUE(bloom_wrapper->insert(ColumnHelper::create_column<DataType>(bloom_data_vector),
 0)
+                        .ok());
+    bloom_wrapper->set_state(RuntimeFilterWrapper::State::READY);
+    char* bloom_data = nullptr;
+    int bloom_len = 0;
+    bloom_wrapper->bloom_filter_func()->get_data(&bloom_data, &bloom_len);
+    ASSERT_GT(bloom_len, 0);
+    const std::string snapshot(bloom_data, bloom_len);
+
+    ASSERT_TRUE(wrapper->merge(bloom_wrapper.get()).ok());
+    EXPECT_EQ(wrapper->get_real_type(), RuntimeFilterType::BLOOM_FILTER);
+    EXPECT_NE(wrapper->bloom_filter_func(), 
bloom_wrapper->bloom_filter_func());
+    char* merged_data = nullptr;
+    int merged_len = 0;
+    wrapper->bloom_filter_func()->get_data(&merged_data, &merged_len);
+    EXPECT_NE(merged_data, bloom_data);
+    EXPECT_EQ(merged_len, bloom_len);
+    // The input bloom filter is unchanged, the merged one has the IN values 
added.
+    EXPECT_EQ(snapshot, std::string(bloom_data, bloom_len));
+    EXPECT_NE(snapshot, std::string(merged_data, merged_len));
+
+    std::vector<int> final_data_vector(20);
+    std::iota(final_data_vector.begin(), final_data_vector.end(), 0);
+    std::vector<uint8_t> final_res(20);
+    wrapper->bloom_filter_func()->find_fixed_len(
+            ColumnHelper::create_column<DataType>(final_data_vector), 
final_res.data());
+    EXPECT_TRUE(
+            std::all_of(final_res.begin(), final_res.end(), [](uint8_t i) -> 
bool { return i; }));
+}
+
+// When the caller tells merge() that `other` is exclusively owned (e.g. an 
RPC-only filter with
+// no other reader, see `RuntimeFilterMergeControllerEntity::merge`), the 
bloom filter is taken
+// over directly instead of cloned.
+TEST_F(RuntimeFilterWrapperTest, 
TestMergeInWithBloomExclusivelyOwnedTakesOwnership) {
+    using DataType = DataTypeInt32;
+    auto make_params = [](int32_t max_in_num) {
+        return RuntimeFilterParams {.filter_id = 0,
+                                    .filter_type = 
RuntimeFilterType::IN_OR_BLOOM_FILTER,
+                                    .column_return_type = 
PrimitiveType::TYPE_INT,
+                                    .null_aware = false,
+                                    .max_in_num = max_in_num,
+                                    .runtime_bloom_filter_min_size = 64,
+                                    .runtime_bloom_filter_max_size = 128,
+                                    .bloom_filter_size = 64,
+                                    .build_bf_by_runtime_size = false,
+                                    .bloom_filter_size_calculated_by_ndv = 
false};
+    };
+    auto in_params = make_params(18);
+    auto wrapper = std::make_shared<RuntimeFilterWrapper>(&in_params);
+    ASSERT_TRUE(wrapper->init(16).ok());
+    ASSERT_EQ(wrapper->get_real_type(), RuntimeFilterType::IN_FILTER);
+    std::vector<int> data_vector(10);
+    std::iota(data_vector.begin(), data_vector.end(), 0);
+    
ASSERT_TRUE(wrapper->insert(ColumnHelper::create_column<DataType>(data_vector), 
0).ok());
+
+    auto bloom_params = make_params(8);
+    auto bloom_wrapper = std::make_shared<RuntimeFilterWrapper>(&bloom_params);
+    ASSERT_TRUE(bloom_wrapper->init(16).ok());
+    ASSERT_EQ(bloom_wrapper->get_real_type(), RuntimeFilterType::BLOOM_FILTER);
+    std::vector<int> bloom_data_vector(10);
+    std::iota(bloom_data_vector.begin(), bloom_data_vector.end(), 10);
+    
ASSERT_TRUE(bloom_wrapper->insert(ColumnHelper::create_column<DataType>(bloom_data_vector),
 0)
+                        .ok());
+    bloom_wrapper->set_state(RuntimeFilterWrapper::State::READY);
+    auto bloom_filter_func_before_merge = bloom_wrapper->bloom_filter_func();
+
+    ASSERT_TRUE(wrapper->merge(bloom_wrapper.get(), 
/*other_exclusively_owned=*/true).ok());
+    EXPECT_EQ(wrapper->get_real_type(), RuntimeFilterType::BLOOM_FILTER);
+    // No clone: the merger took over `other`'s bloom filter directly.
+    EXPECT_EQ(wrapper->bloom_filter_func(), bloom_filter_func_before_merge);
+    EXPECT_EQ(wrapper->bloom_filter_func(), 
bloom_wrapper->bloom_filter_func());
+
+    std::vector<int> final_data_vector(20);
+    std::iota(final_data_vector.begin(), final_data_vector.end(), 0);
+    std::vector<uint8_t> final_res(20);
+    wrapper->bloom_filter_func()->find_fixed_len(
+            ColumnHelper::create_column<DataType>(final_data_vector), 
final_res.data());
+    EXPECT_TRUE(
+            std::all_of(final_res.begin(), final_res.end(), [](uint8_t i) -> 
bool { return i; }));
+}
+
+TEST_F(RuntimeFilterWrapperTest, TestClone) {
+    using DataType = DataTypeInt32;
+    auto make_params = [](RuntimeFilterType filter_type, bool null_aware, 
int32_t max_in_num,
+                          PrimitiveType column_type = PrimitiveType::TYPE_INT) 
{
+        return RuntimeFilterParams {.filter_id = 3,
+                                    .filter_type = filter_type,
+                                    .column_return_type = column_type,
+                                    .null_aware = null_aware,
+                                    .max_in_num = max_in_num,
+                                    .runtime_bloom_filter_min_size = 64,
+                                    .runtime_bloom_filter_max_size = 128,
+                                    .bloom_filter_size = 64,
+                                    .build_bf_by_runtime_size = false,
+                                    .bloom_filter_size_calculated_by_ndv = 
true};
+    };
+    {
+        // In filter, the copy starts with an empty bucket prune cache.
+        auto params = make_params(RuntimeFilterType::IN_FILTER, true, 8);
+        auto wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+        ASSERT_TRUE(wrapper->init(3).ok());
+        ASSERT_TRUE(wrapper->insert(ColumnHelper::create_column<DataType>({1, 
2}), 0).ok());
+        wrapper->hybrid_set()->insert((const void*)nullptr);
+        wrapper->set_state(RuntimeFilterWrapper::State::READY);
+        auto hashes = 
wrapper->get_or_compute_bucket_prune_hashes(std::make_shared<DataType>());
+        ASSERT_EQ(hashes->size(), 3);
+
+        std::shared_ptr<RuntimeFilterWrapper> cloned;
+        ASSERT_TRUE(wrapper->clone(&cloned).ok());
+        EXPECT_NE(cloned, wrapper);
+        EXPECT_EQ(cloned->get_state(), RuntimeFilterWrapper::State::READY);
+        EXPECT_EQ(cloned->filter_id(), wrapper->filter_id());
+        EXPECT_EQ(cloned->get_real_type(), RuntimeFilterType::IN_FILTER);
+        EXPECT_EQ(cloned->_max_in_num, wrapper->_max_in_num);
+        EXPECT_NE(cloned->hybrid_set(), wrapper->hybrid_set());
+        EXPECT_EQ(cloned->hybrid_set()->size(), 2);
+        int32_t value = 1;
+        EXPECT_TRUE(cloned->hybrid_set()->find(&value));
+        value = 2;
+        EXPECT_TRUE(cloned->hybrid_set()->find(&value));
+        EXPECT_TRUE(cloned->contain_null());
+        EXPECT_FALSE(cloned->_bucket_prune_hashes_started.load());
+
+        value = 3;
+        cloned->hybrid_set()->insert(&value);
+        EXPECT_EQ(wrapper->hybrid_set()->size(), 2);
+        EXPECT_FALSE(wrapper->hybrid_set()->find(&value));
+        value = 4;
+        wrapper->hybrid_set()->insert(&value);
+        EXPECT_EQ(cloned->hybrid_set()->size(), 3);
+        EXPECT_FALSE(cloned->hybrid_set()->find(&value));
+    }
+    {
+        // MinMax filter
+        auto params = make_params(RuntimeFilterType::MINMAX_FILTER, false, 0);
+        auto wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+        ASSERT_TRUE(wrapper->insert(ColumnHelper::create_column<DataType>({3, 
7}), 0).ok());
+        wrapper->set_state(RuntimeFilterWrapper::State::READY);
+
+        std::shared_ptr<RuntimeFilterWrapper> cloned;
+        ASSERT_TRUE(wrapper->clone(&cloned).ok());
+        EXPECT_EQ(cloned->get_state(), RuntimeFilterWrapper::State::READY);
+        EXPECT_NE(cloned->minmax_func(), wrapper->minmax_func());
+        EXPECT_EQ(*(int32_t*)cloned->minmax_func()->get_min(), 3);
+        EXPECT_EQ(*(int32_t*)cloned->minmax_func()->get_max(), 7);
+
+        ASSERT_TRUE(cloned->insert(ColumnHelper::create_column<DataType>({1, 
9}), 0).ok());
+        EXPECT_EQ(*(int32_t*)wrapper->minmax_func()->get_min(), 3);
+        EXPECT_EQ(*(int32_t*)wrapper->minmax_func()->get_max(), 7);
+    }
+    {
+        // Bloom filter
+        auto params = make_params(RuntimeFilterType::BLOOM_FILTER, false, 0);
+        auto wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+        ASSERT_TRUE(wrapper->init(80).ok());
+        std::vector<int32_t> data(10);
+        std::iota(data.begin(), data.end(), 0);
+        
ASSERT_TRUE(wrapper->insert(ColumnHelper::create_column<DataType>(data), 
0).ok());
+        wrapper->set_state(RuntimeFilterWrapper::State::READY);
+
+        std::shared_ptr<RuntimeFilterWrapper> cloned;
+        ASSERT_TRUE(wrapper->clone(&cloned).ok());
+        EXPECT_EQ(cloned->get_state(), RuntimeFilterWrapper::State::READY);
+        EXPECT_NE(cloned->bloom_filter_func(), wrapper->bloom_filter_func());
+        ASSERT_EQ(cloned->bloom_filter_func()->get_size(),
+                  wrapper->bloom_filter_func()->get_size());
+        char* origin_data = nullptr;
+        int origin_len = 0;
+        wrapper->bloom_filter_func()->get_data(&origin_data, &origin_len);
+        char* cloned_data = nullptr;
+        int cloned_len = 0;
+        cloned->bloom_filter_func()->get_data(&cloned_data, &cloned_len);
+        ASSERT_EQ(cloned_len, origin_len);
+        EXPECT_NE(cloned_data, origin_data);
+        EXPECT_EQ(memcmp(cloned_data, origin_data, origin_len), 0);
+
+        std::string snapshot(origin_data, origin_len);
+        ASSERT_TRUE(cloned->insert(ColumnHelper::create_column<DataType>({100, 
200, 300}), 0).ok());
+        EXPECT_EQ(memcmp(snapshot.data(), origin_data, origin_len), 0);
+    }
+    {
+        // In or bloom filter keeps its real type.
+        auto params = make_params(RuntimeFilterType::IN_OR_BLOOM_FILTER, 
false, 2);
+        auto in_wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+        ASSERT_TRUE(in_wrapper->init(1).ok());
+        
ASSERT_TRUE(in_wrapper->insert(ColumnHelper::create_column<DataType>({1}), 
0).ok());
+        in_wrapper->set_state(RuntimeFilterWrapper::State::READY);
+        // `init()` leaves the bloom filter of an IN filter unallocated, so 
allocate it by hand:
+        // the copy must skip the unused bloom filter, not merely find none to 
copy.
+        
ASSERT_TRUE(in_wrapper->bloom_filter_func()->init_with_fixed_length(1).ok());
+        ASSERT_GT(in_wrapper->bloom_filter_func()->get_size(), 0);
+        std::shared_ptr<RuntimeFilterWrapper> cloned;
+        ASSERT_TRUE(in_wrapper->clone(&cloned).ok());
+        EXPECT_EQ(cloned->get_real_type(), RuntimeFilterType::IN_FILTER);
+        EXPECT_EQ(cloned->hybrid_set()->size(), 1);
+        EXPECT_NE(cloned->bloom_filter_func(), 
in_wrapper->bloom_filter_func());
+        // The unused bloom filter of an IN filter is not copied.
+        EXPECT_EQ(cloned->bloom_filter_func()->get_size(), 0);
+
+        auto bloom_wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+        ASSERT_TRUE(bloom_wrapper->init(3).ok());
+        ASSERT_TRUE(
+                
bloom_wrapper->insert(ColumnHelper::create_column<DataType>({1, 2, 3}), 
0).ok());
+        bloom_wrapper->set_state(RuntimeFilterWrapper::State::READY);
+        ASSERT_TRUE(bloom_wrapper->clone(&cloned).ok());
+        EXPECT_EQ(cloned->get_real_type(), RuntimeFilterType::BLOOM_FILTER);
+        EXPECT_EQ(cloned->hybrid_set(), nullptr);
+        EXPECT_EQ(cloned->bloom_filter_func()->get_size(),
+                  bloom_wrapper->bloom_filter_func()->get_size());
+    }
+    {
+        // A disabled filter which is never initialized has no bloom filter 
allocated.
+        auto params = make_params(RuntimeFilterType::IN_OR_BLOOM_FILTER, 
false, 2);
+        auto wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+        wrapper->set_state(RuntimeFilterWrapper::State::DISABLED, "some 
reason");
+        std::shared_ptr<RuntimeFilterWrapper> cloned;
+        ASSERT_TRUE(wrapper->clone(&cloned).ok());
+        EXPECT_EQ(cloned->get_state(), RuntimeFilterWrapper::State::DISABLED);
+        EXPECT_NE(cloned->_reason.status().msg().find("some reason"), 
std::string::npos);
+        EXPECT_EQ(cloned->bloom_filter_func()->get_size(), 0);
+    }
+    {
+        // String in filter, the copy owns its strings.
+        auto params =
+                make_params(RuntimeFilterType::IN_FILTER, false, 8, 
PrimitiveType::TYPE_STRING);
+        auto wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+        ASSERT_TRUE(wrapper->init(2).ok());
+        const std::string long_value(64, 'x');
+        ASSERT_TRUE(
+                
wrapper->insert(ColumnHelper::create_column<DataTypeString>({"a", long_value}), 
0)
+                        .ok());
+        wrapper->set_state(RuntimeFilterWrapper::State::READY);
+        std::shared_ptr<RuntimeFilterWrapper> cloned;
+        ASSERT_TRUE(wrapper->clone(&cloned).ok());
+        wrapper->hybrid_set()->clear();
+        wrapper.reset();
+        EXPECT_EQ(cloned->hybrid_set()->size(), 2);
+        StringRef value("a");
+        EXPECT_TRUE(cloned->hybrid_set()->find(&value));
+        value = StringRef(long_value);
+        EXPECT_TRUE(cloned->hybrid_set()->find(&value));
+    }
+    {
+        // Null aware bloom filter and minmax filter keep `contain_null`.
+        auto column = ColumnHelper::create_nullable_column<DataType>({1, 2, 
0}, {0, 0, 1});
+        for (auto filter_type :
+             {RuntimeFilterType::BLOOM_FILTER, 
RuntimeFilterType::MINMAX_FILTER}) {
+            auto params = make_params(filter_type, true, 0);
+            auto wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+            ASSERT_TRUE(wrapper->init(3).ok());
+            ASSERT_TRUE(wrapper->insert(column, 0).ok());
+            wrapper->set_state(RuntimeFilterWrapper::State::READY);
+            ASSERT_TRUE(wrapper->contain_null());
+            std::shared_ptr<RuntimeFilterWrapper> cloned;
+            ASSERT_TRUE(wrapper->clone(&cloned).ok());
+            EXPECT_EQ(cloned->get_real_type(), filter_type);
+            EXPECT_TRUE(cloned->contain_null());
+        }
+    }
+    {
+        // Min filter and max filter
+        for (auto filter_type : {RuntimeFilterType::MIN_FILTER, 
RuntimeFilterType::MAX_FILTER}) {
+            auto params = make_params(filter_type, false, 0);
+            auto wrapper = std::make_shared<RuntimeFilterWrapper>(&params);
+            
ASSERT_TRUE(wrapper->insert(ColumnHelper::create_column<DataType>({3, 7}), 
0).ok());
+            wrapper->set_state(RuntimeFilterWrapper::State::READY);
+            std::shared_ptr<RuntimeFilterWrapper> cloned;
+            ASSERT_TRUE(wrapper->clone(&cloned).ok());
+            EXPECT_EQ(cloned->get_real_type(), filter_type);
+            EXPECT_NE(cloned->minmax_func(), wrapper->minmax_func());
+            
ASSERT_TRUE(cloned->insert(ColumnHelper::create_column<DataType>({1, 9}), 
0).ok());
+            if (filter_type == RuntimeFilterType::MIN_FILTER) {
+                EXPECT_EQ(*(int32_t*)cloned->minmax_func()->get_min(), 1);
+                EXPECT_EQ(*(int32_t*)wrapper->minmax_func()->get_min(), 3);
+            } else {
+                EXPECT_EQ(*(int32_t*)cloned->minmax_func()->get_max(), 9);
+                EXPECT_EQ(*(int32_t*)wrapper->minmax_func()->get_max(), 7);
+            }
+        }
+    }
+}
+
 TEST_F(RuntimeFilterWrapperTest, TestErrorPath) {
     using DataType = DataTypeInt32;
     int32_t filter_id = 0;


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

Reply via email to