blob: a7cd7cda5958388567074eb07d3a3839364975f9 [file]
// 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.
#include "exec/operator/hashjoin_build_sink.h"
#include <gen_cpp/DataSinks_types.h>
#include <gen_cpp/Descriptors_types.h>
#include <gen_cpp/PlanNodes_types.h>
#include <gen_cpp/Types_types.h>
#include <gmock/gmock-actions.h>
#include <gmock/gmock-function-mocker.h>
#include <gmock/gmock-spec-builders.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <memory>
#include <variant>
#include <vector>
#include "common/config.h"
#include "core/block/block.h"
#include "core/data_type/data_type_number.h"
#include "exec/operator/hash_join_test_helper.h"
#include "exec/operator/operator.h"
#include "exec/pipeline/pipeline_task.h"
#include "exprs/vexpr_context.h"
#include "runtime/descriptors.h"
#include "runtime/runtime_state.h"
#include "testutil/column_helper.h"
#include "testutil/mock/mock_runtime_state.h"
namespace doris {
class HashJoinBuildSinkTest : public testing::Test {
public:
void SetUp() override { _helper.SetUp(); }
void TearDown() override { _helper.TearDown(); }
template <typename Func>
void run_test_block(Func test_block) {
auto testing_join_ops = {TJoinOp::INNER_JOIN,
TJoinOp::LEFT_OUTER_JOIN,
TJoinOp::RIGHT_OUTER_JOIN,
TJoinOp::FULL_OUTER_JOIN,
TJoinOp::LEFT_SEMI_JOIN,
TJoinOp::RIGHT_SEMI_JOIN,
TJoinOp::LEFT_ANTI_JOIN,
TJoinOp::RIGHT_ANTI_JOIN,
TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN,
TJoinOp::NULL_AWARE_LEFT_SEMI_JOIN};
auto testing_key_types = {
TPrimitiveType::BOOLEAN, TPrimitiveType::TINYINT, TPrimitiveType::SMALLINT,
TPrimitiveType::INT, TPrimitiveType::BIGINT, TPrimitiveType::FLOAT,
TPrimitiveType::DOUBLE, TPrimitiveType::DATE, TPrimitiveType::DATETIME,
TPrimitiveType::BINARY, TPrimitiveType::CHAR, TPrimitiveType::LARGEINT,
TPrimitiveType::VARCHAR, TPrimitiveType::DECIMALV2, TPrimitiveType::STRING,
TPrimitiveType::DATEV2, TPrimitiveType::DATETIMEV2, TPrimitiveType::TIMEV2,
TPrimitiveType::DECIMAL32, TPrimitiveType::DECIMAL64, TPrimitiveType::DECIMAL128I,
TPrimitiveType::DECIMAL256, TPrimitiveType::IPV4, TPrimitiveType::IPV6};
for (const auto& op_type : testing_join_ops) {
for (const auto key_type : testing_key_types) {
for (auto left_nullable : {true, false}) {
for (auto right_nullable : {true, false}) {
test_block(op_type, {key_type}, {left_nullable}, {right_nullable});
}
}
for (const auto key_type2 : testing_key_types) {
for (auto left_nullable : {true, false}) {
for (auto right_nullable : {true, false}) {
test_block(op_type, {key_type, key_type2},
{left_nullable, right_nullable},
{right_nullable, left_nullable});
}
}
}
}
}
}
protected:
HashJoinTestHelper _helper;
};
TEST_F(HashJoinBuildSinkTest, Init) {
auto test_block = [&](TJoinOp::type op_type, const std::vector<TPrimitiveType::type>& key_types,
const std::vector<bool>& left_nullables,
const std::vector<bool>& right_nullables) {
auto tnode =
_helper.create_test_plan_node(op_type, key_types, left_nullables, right_nullables);
auto [probe_operator, sink_operator] = _helper.create_operators(tnode);
ASSERT_TRUE(probe_operator);
ASSERT_TRUE(sink_operator);
auto runtime_state = std::make_unique<MockRuntimeState>();
runtime_state->_query_ctx = _helper.query_ctx.get();
runtime_state->_query_id = _helper.query_ctx->query_id();
runtime_state->resize_op_id_to_local_state(-100);
runtime_state->set_max_operator_id(-100);
runtime_state->set_desc_tbl(_helper.desc_tbl);
auto st = sink_operator->init(tnode, runtime_state.get());
ASSERT_TRUE(st.ok()) << "init failed: " << st.to_string();
st = sink_operator->prepare(runtime_state.get());
ASSERT_TRUE(st.ok()) << "prepare failed: " << st.to_string();
auto shared_state = sink_operator->create_shared_state();
ASSERT_TRUE(shared_state);
std::map<int, std::pair<std::shared_ptr<BasicSharedState>,
std::vector<std::shared_ptr<Dependency>>>>
shared_state_map;
LocalSinkStateInfo info {.task_idx = 0,
.parent_profile = _helper.runtime_profile.get(),
.sender_id = 0,
.shared_state = shared_state.get(),
.shared_state_map = shared_state_map,
.tsink = TDataSink()};
st = sink_operator->setup_local_state(runtime_state.get(), info);
ASSERT_TRUE(st.ok()) << "setup_local_state failed: " << st.to_string();
st = probe_operator->init(tnode, runtime_state.get());
ASSERT_TRUE(st.ok()) << "init failed: " << st.to_string();
st = probe_operator->prepare(runtime_state.get());
ASSERT_TRUE(st.ok()) << "prepare failed: " << st.to_string();
LocalStateInfo info2 {.parent_profile = _helper.runtime_profile.get(),
.scan_ranges = {},
.shared_state = shared_state.get(),
.shared_state_map = shared_state_map,
.task_idx = 0};
st = probe_operator->setup_local_state(runtime_state.get(), info2);
ASSERT_TRUE(st.ok()) << "setup_local_state failed: " << st.to_string();
auto* probe_local_state = runtime_state->get_local_state(probe_operator->operator_id());
ASSERT_TRUE(probe_local_state);
st = probe_local_state->open(runtime_state.get());
ASSERT_TRUE(st.ok()) << "open failed: " << st.to_string();
auto* local_state = reinterpret_cast<HashJoinBuildSinkLocalState*>(
runtime_state->get_sink_local_state());
ASSERT_TRUE(local_state);
st = local_state->open(runtime_state.get());
ASSERT_TRUE(st.ok()) << "open failed: " << st.to_string();
ASSERT_GT(sink_operator->get_memory_usage_debug_str(runtime_state.get()).size(), 0);
st = local_state->close(runtime_state.get(), st);
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
st = sink_operator->close(runtime_state.get(), st);
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
st = probe_operator->close(runtime_state.get());
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
st = probe_local_state->close(runtime_state.get());
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
};
run_test_block(test_block);
}
TEST_F(HashJoinBuildSinkTest, Sink) {
auto test_block = [&](TJoinOp::type op_type, const std::vector<TPrimitiveType::type>& key_types,
const std::vector<bool>& left_nullables,
const std::vector<bool>& right_nullables) {
auto tnode =
_helper.create_test_plan_node(op_type, key_types, left_nullables, right_nullables);
auto [probe_operator, sink_operator] = _helper.create_operators(tnode);
ASSERT_TRUE(probe_operator);
ASSERT_TRUE(sink_operator);
auto runtime_state = std::make_unique<MockRuntimeState>();
runtime_state->_query_ctx = _helper.query_ctx.get();
runtime_state->_query_id = _helper.query_ctx->query_id();
runtime_state->resize_op_id_to_local_state(-100);
runtime_state->set_max_operator_id(-100);
runtime_state->set_desc_tbl(_helper.desc_tbl);
auto st = sink_operator->init(TDataSink());
ASSERT_FALSE(st.ok());
st = sink_operator->init(tnode, runtime_state.get());
ASSERT_TRUE(st.ok()) << "init failed: " << st.to_string();
st = sink_operator->prepare(runtime_state.get());
ASSERT_TRUE(st.ok()) << "prepare failed: " << st.to_string();
auto shared_state = sink_operator->create_shared_state();
ASSERT_TRUE(shared_state);
shared_state->create_source_dependency(sink_operator->operator_id(), tnode.node_id,
"HashJoinSinkTestDep");
std::map<int, std::pair<std::shared_ptr<BasicSharedState>,
std::vector<std::shared_ptr<Dependency>>>>
shared_state_map;
LocalSinkStateInfo info {.task_idx = 0,
.parent_profile = _helper.runtime_profile.get(),
.sender_id = 0,
.shared_state = shared_state.get(),
.shared_state_map = shared_state_map,
.tsink = TDataSink()};
st = sink_operator->setup_local_state(runtime_state.get(), info);
ASSERT_TRUE(st.ok()) << "setup_local_state failed: " << st.to_string();
auto* local_state = reinterpret_cast<HashJoinBuildSinkLocalState*>(
runtime_state->get_sink_local_state());
ASSERT_TRUE(local_state);
st = local_state->open(runtime_state.get());
ASSERT_TRUE(st.ok()) << "open failed: " << st.to_string();
ASSERT_EQ(sink_operator->get_reserve_mem_size(runtime_state.get(), false), 0);
const auto& row_desc = sink_operator->child()->row_desc();
Block block(row_desc.tuple_descriptors()[0]->slots(), 0);
auto mutable_block = MutableBlock(block.clone_empty());
for (auto& col : mutable_block.mutable_columns()) {
col->insert_default();
if (col->is_nullable()) {
auto& nullable_column = assert_cast<ColumnNullable&>(*col);
nullable_column.insert_not_null_elements(1);
} else {
col->insert_default();
}
}
auto block2 = mutable_block.to_block();
st = sink_operator->sink(runtime_state.get(), &block2, false);
ASSERT_TRUE(st.ok()) << "sink failed: " << st.to_string();
ASSERT_GT(sink_operator->get_reserve_mem_size(runtime_state.get(), true), 0);
st = sink_operator->sink(runtime_state.get(), &block, true);
ASSERT_TRUE(st.ok()) << "sink failed: " << st.to_string();
ASSERT_EQ(sink_operator->get_reserve_mem_size(runtime_state.get(), true), 0);
ASSERT_GT(sink_operator->get_memory_usage(runtime_state.get()), 0);
st = local_state->close(runtime_state.get(), st);
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
st = sink_operator->close(runtime_state.get(), st);
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
};
run_test_block(test_block);
}
TEST_F(HashJoinBuildSinkTest, Terminate) {
auto test_block = [&](TJoinOp::type op_type, const std::vector<TPrimitiveType::type>& key_types,
const std::vector<bool>& left_nullables,
const std::vector<bool>& right_nullables) {
auto tnode =
_helper.create_test_plan_node(op_type, key_types, left_nullables, right_nullables);
auto [probe_operator, sink_operator] = _helper.create_operators(tnode);
ASSERT_TRUE(probe_operator);
ASSERT_TRUE(sink_operator);
auto runtime_state = std::make_unique<MockRuntimeState>();
runtime_state->_query_ctx = _helper.query_ctx.get();
runtime_state->_query_id = _helper.query_ctx->query_id();
runtime_state->resize_op_id_to_local_state(-100);
runtime_state->set_max_operator_id(-100);
runtime_state->set_desc_tbl(_helper.desc_tbl);
auto st = sink_operator->init(TDataSink());
ASSERT_FALSE(st.ok());
st = sink_operator->init(tnode, runtime_state.get());
ASSERT_TRUE(st.ok()) << "init failed: " << st.to_string();
st = sink_operator->prepare(runtime_state.get());
ASSERT_TRUE(st.ok()) << "prepare failed: " << st.to_string();
auto shared_state = sink_operator->create_shared_state();
ASSERT_TRUE(shared_state);
shared_state->create_source_dependency(sink_operator->operator_id(), tnode.node_id,
"HashJoinSinkTestDep");
std::map<int, std::pair<std::shared_ptr<BasicSharedState>,
std::vector<std::shared_ptr<Dependency>>>>
shared_state_map;
LocalSinkStateInfo info {.task_idx = 0,
.parent_profile = _helper.runtime_profile.get(),
.sender_id = 0,
.shared_state = shared_state.get(),
.shared_state_map = shared_state_map,
.tsink = TDataSink()};
st = sink_operator->setup_local_state(runtime_state.get(), info);
ASSERT_TRUE(st.ok()) << "setup_local_state failed: " << st.to_string();
auto* local_state = reinterpret_cast<HashJoinBuildSinkLocalState*>(
runtime_state->get_sink_local_state());
ASSERT_TRUE(local_state);
st = local_state->open(runtime_state.get());
ASSERT_TRUE(st.ok()) << "open failed: " << st.to_string();
ASSERT_EQ(sink_operator->get_reserve_mem_size(runtime_state.get(), false), 0);
const auto& row_desc = sink_operator->child()->row_desc();
Block block(row_desc.tuple_descriptors()[0]->slots(), 0);
auto mutable_block = MutableBlock(block.clone_empty());
for (auto& col : mutable_block.mutable_columns()) {
col->insert_default();
if (col->is_nullable()) {
auto& nullable_column = assert_cast<ColumnNullable&>(*col);
nullable_column.insert_not_null_elements(1);
} else {
col->insert_default();
}
}
auto block2 = mutable_block.to_block();
st = sink_operator->sink(runtime_state.get(), &block2, false);
ASSERT_TRUE(st.ok()) << "sink failed: " << st.to_string();
st = sink_operator->terminate(runtime_state.get());
ASSERT_TRUE(st.ok()) << "terminate failed: " << st.to_string();
ASSERT_GT(sink_operator->get_reserve_mem_size(runtime_state.get(), true), 0);
st = sink_operator->sink(runtime_state.get(), &block, true);
ASSERT_TRUE(st.ok()) << "sink failed: " << st.to_string();
ASSERT_EQ(sink_operator->get_reserve_mem_size(runtime_state.get(), true), 0);
ASSERT_GT(sink_operator->get_memory_usage(runtime_state.get()), 0);
st = local_state->close(runtime_state.get(), st);
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
st = sink_operator->close(runtime_state.get(), st);
ASSERT_TRUE(st.ok()) << "close failed: " << st.to_string();
};
run_test_block(test_block);
}
// ============================================================================
// Tests for shared hash table signaling when builder task is terminated.
//
// The problematic timing sequence (before the fix):
// Thread A: triggers make_all_runnable
// Thread A: make_all_runnable wakes up task0 (the builder)
// Thread B: task0 is terminated (wake_up_early) and close() runs ->
// unconditionally sets _signaled=true and wakes other tasks
// Thread C: task1 (non-builder) wakes up, calls sink() ->
// sees _signaled=true, enters std::visit on monostate hash table -> crash
// Thread A: make_all_runnable continues
//
// The fix: In close(), only set _signaled=true when the builder was NOT terminated
// (i.e., the hash table was actually built). When terminated, _signaled stays false,
// so non-builder tasks return EOF safely.
// ============================================================================
class SharedHashTableSignalTest : public testing::Test {
public:
void SetUp() override { _helper.SetUp(); }
void TearDown() override { _helper.TearDown(); }
protected:
// Set up a broadcast join with shared hash table containing num_instances tasks.
// Returns the sink operator, shared state, and runtime states for all tasks.
struct BroadcastJoinSetup {
std::shared_ptr<HashJoinBuildSinkOperatorX> sink_op;
std::shared_ptr<HashJoinProbeOperatorX> probe_op;
std::shared_ptr<BasicSharedState> shared_state;
// runtime_state for the builder (task_idx=0)
MockRuntimeState* builder_state;
// runtime_states for non-builder tasks (task_idx=1..N-1)
std::vector<std::shared_ptr<MockRuntimeState>> non_builder_states;
};
BroadcastJoinSetup setup_broadcast_join(int num_instances) {
BroadcastJoinSetup result;
auto tnode = _helper.create_test_plan_node(TJoinOp::INNER_JOIN, {TPrimitiveType::INT},
{false}, {false});
tnode.hash_join_node.__isset.is_broadcast_join = true;
tnode.hash_join_node.is_broadcast_join = true;
auto [probe_op, sink_op] = _helper.create_operators(tnode);
EXPECT_TRUE(probe_op);
EXPECT_TRUE(sink_op);
auto st = sink_op->init(tnode, _helper.runtime_state.get());
EXPECT_TRUE(st.ok()) << st.to_string();
st = sink_op->prepare(_helper.runtime_state.get());
EXPECT_TRUE(st.ok()) << st.to_string();
st = probe_op->init(tnode, _helper.runtime_state.get());
EXPECT_TRUE(st.ok()) << st.to_string();
st = probe_op->prepare(_helper.runtime_state.get());
EXPECT_TRUE(st.ok()) << st.to_string();
// Create shared state for broadcast join with num_instances
auto shared_state = HashJoinSharedState::create_shared(num_instances);
shared_state->create_source_dependencies(num_instances, sink_op->operator_id(),
sink_op->node_id(), "HASH_JOIN_PROBE");
// Set up non-builder tasks first (task_idx=1..N-1)
for (int i = 1; i < num_instances; i++) {
auto non_builder_state = std::make_shared<MockRuntimeState>();
non_builder_state->_query_ctx = _helper.runtime_state->_query_ctx;
non_builder_state->_query_id = _helper.runtime_state->_query_id;
non_builder_state->resize_op_id_to_local_state(-100);
non_builder_state->set_max_operator_id(-100);
LocalSinkStateInfo info {
.task_idx = i,
.parent_profile = _helper.runtime_profile.get(),
.sender_id = 0,
.shared_state = shared_state.get(),
.shared_state_map = {},
.tsink = TDataSink(),
};
st = sink_op->setup_local_state(non_builder_state.get(), info);
EXPECT_TRUE(st.ok()) << st.to_string();
auto* local_state = assert_cast<HashJoinBuildSinkLocalState*>(
non_builder_state->get_sink_local_state());
EXPECT_TRUE(local_state);
st = local_state->open(non_builder_state.get());
EXPECT_TRUE(st.ok()) << st.to_string();
// Non-builder should have _should_build_hash_table=false
EXPECT_FALSE(local_state->_should_build_hash_table);
result.non_builder_states.push_back(non_builder_state);
}
// Set up builder task (task_idx=0) using _helper.runtime_state
LocalSinkStateInfo builder_info {
.task_idx = 0,
.parent_profile = _helper.runtime_profile.get(),
.sender_id = 0,
.shared_state = shared_state.get(),
.shared_state_map = {},
.tsink = TDataSink(),
};
st = sink_op->setup_local_state(_helper.runtime_state.get(), builder_info);
EXPECT_TRUE(st.ok()) << st.to_string();
auto* builder_local_state = assert_cast<HashJoinBuildSinkLocalState*>(
_helper.runtime_state->get_sink_local_state());
EXPECT_TRUE(builder_local_state);
st = builder_local_state->open(_helper.runtime_state.get());
EXPECT_TRUE(st.ok()) << st.to_string();
// Builder should have _should_build_hash_table=true
EXPECT_TRUE(builder_local_state->_should_build_hash_table);
result.sink_op = sink_op;
result.probe_op = probe_op;
result.shared_state = shared_state;
result.builder_state = _helper.runtime_state.get();
return result;
}
HashJoinTestHelper _helper;
};
// Test the fix: when the builder task is terminated (woken up early because the probe side
// finished first), close() should NOT set _signaled=true. This prevents non-builder tasks
// from entering std::visit on an uninitialized (monostate) hash table.
//
// Reproduces the problematic timing sequence:
// 1. Builder task is woken up and gets terminated (wake_up_early)
// 2. Builder's terminate() sets _terminated=true (like pipeline_task.cpp's Defer)
// 3. Builder's close() runs — should NOT set _signaled=true
// 4. Non-builder task calls sink(eos=true) — should see _signaled=false and return EOF
TEST_F(SharedHashTableSignalTest, BuilderTerminatedDoesNotSignal) {
auto setup = setup_broadcast_join(2);
auto* builder_local_state =
assert_cast<HashJoinBuildSinkLocalState*>(setup.builder_state->get_sink_local_state());
// Verify initial state: _signaled should be false, hash table should be monostate
ASSERT_FALSE(setup.sink_op->_signaled);
ASSERT_TRUE(
std::holds_alternative<std::monostate>(setup.shared_state->cast<HashJoinSharedState>()
->hash_table_variant_vector.front()
->method_variant));
// Step 1-2: Simulate the builder being terminated (wake_up_early path).
// In the real code path, pipeline_task.cpp's execute() Defer calls terminate()
// BEFORE close() runs.
auto st = builder_local_state->terminate(setup.builder_state);
ASSERT_TRUE(st.ok()) << st.to_string();
ASSERT_TRUE(builder_local_state->_terminated);
// Step 3: Builder's close() runs. With the fix, _signaled should stay false
// because the builder was terminated without building the hash table.
st = setup.sink_op->close(setup.builder_state, Status::OK());
ASSERT_TRUE(st.ok()) << st.to_string();
// Key assertion: _signaled must remain false after terminated builder's close()
ASSERT_FALSE(setup.sink_op->_signaled) << "_signaled should NOT be set when builder was "
"terminated without building hash table";
// Step 4: Non-builder task calls sink(eos=true).
// It should see _signaled=false and return EOF, NOT crash on monostate.
auto* non_builder_state = setup.non_builder_states[0].get();
Block empty_block;
st = setup.sink_op->sink(non_builder_state, &empty_block, true);
ASSERT_TRUE(st.is<ErrorCode::END_OF_FILE>())
<< "Non-builder should return EOF when builder was terminated, got: " << st.to_string();
// Clean up the non-builder
st = setup.sink_op->close(non_builder_state, Status::OK());
ASSERT_TRUE(st.ok()) << st.to_string();
}
// Test the normal path: when the builder completes normally (not terminated),
// close() should set _signaled=true, and non-builder tasks should proceed
// to share the hash table successfully.
TEST_F(SharedHashTableSignalTest, BuilderNormalCompletionSignals) {
auto setup = setup_broadcast_join(2);
auto* builder_local_state =
assert_cast<HashJoinBuildSinkLocalState*>(setup.builder_state->get_sink_local_state());
// Verify initial state
ASSERT_FALSE(setup.sink_op->_signaled);
ASSERT_FALSE(builder_local_state->_terminated);
// Builder sinks data normally
auto build_block = ColumnHelper::create_block<DataTypeInt32>({1, 2, 3});
auto st = setup.sink_op->sink(setup.builder_state, &build_block, false);
ASSERT_TRUE(st.ok()) << st.to_string();
// Builder receives eos
Block empty_block;
st = setup.sink_op->sink(setup.builder_state, &empty_block, true);
ASSERT_TRUE(st.ok()) << st.to_string();
// Hash table should have been built (no longer monostate)
ASSERT_FALSE(
std::holds_alternative<std::monostate>(setup.shared_state->cast<HashJoinSharedState>()
->hash_table_variant_vector.front()
->method_variant));
// Builder closes normally (not terminated)
ASSERT_FALSE(builder_local_state->_terminated);
st = setup.sink_op->close(setup.builder_state, Status::OK());
ASSERT_TRUE(st.ok()) << st.to_string();
// Key assertion: _signaled should be true after normal builder close
ASSERT_TRUE(setup.sink_op->_signaled)
<< "_signaled should be set when builder completed normally";
// Non-builder task calls sink(eos=true) — should succeed, sharing the hash table
auto* non_builder_state = setup.non_builder_states[0].get();
st = setup.sink_op->sink(non_builder_state, &empty_block, true);
ASSERT_TRUE(st.ok()) << "Non-builder should succeed when builder signaled, got: "
<< st.to_string();
// Clean up
st = setup.sink_op->close(non_builder_state, Status::OK());
ASSERT_TRUE(st.ok()) << st.to_string();
}
// Test with multiple non-builder tasks: when builder is terminated,
// ALL non-builder tasks should return EOF safely (no crash on monostate).
TEST_F(SharedHashTableSignalTest, MultipleNonBuildersAllReturnEOFWhenTerminated) {
constexpr int num_instances = 4;
auto setup = setup_broadcast_join(num_instances);
auto* builder_local_state =
assert_cast<HashJoinBuildSinkLocalState*>(setup.builder_state->get_sink_local_state());
// Terminate the builder (simulate wake_up_early)
auto st = builder_local_state->terminate(setup.builder_state);
ASSERT_TRUE(st.ok()) << st.to_string();
// Builder close — should NOT set _signaled
st = setup.sink_op->close(setup.builder_state, Status::OK());
ASSERT_TRUE(st.ok()) << st.to_string();
ASSERT_FALSE(setup.sink_op->_signaled);
// All non-builder tasks should return EOF safely
for (int i = 0; i < num_instances - 1; i++) {
auto* non_builder_state = setup.non_builder_states[i].get();
Block empty_block;
st = setup.sink_op->sink(non_builder_state, &empty_block, true);
ASSERT_TRUE(st.is<ErrorCode::END_OF_FILE>())
<< "Non-builder task " << (i + 1) << " should return EOF, got: " << st.to_string();
st = setup.sink_op->close(non_builder_state, Status::OK());
ASSERT_TRUE(st.ok()) << st.to_string();
}
}
} // namespace doris