From de5cd1031dbe9364b450e3457b60334b30038061 Mon Sep 17 00:00:00 2001 From: xufei Date: Wed, 8 Apr 2026 20:30:16 +0800 Subject: [PATCH 01/11] Support null-eq join (#10788) ref pingcap/tiflash#10787 Support null-eq join in TiFlash by plumbing join metadata from DAG/planner into join execution, handling nullable null-eq keys correctly in hash join, refining row-filter/null-key handling for outer/full join paths, disabling incompatible runtime-filter paths, and adding targeted test coverage. This branch also keeps the existing json_object pushdown changes already present on the branch. Signed-off-by: xufei --- dbms/src/Debug/MockExecutor/JoinBinder.cpp | 8 +- dbms/src/Debug/MockExecutor/JoinBinder.h | 4 + .../Coprocessor/JoinInterpreterHelper.cpp | 80 ++ .../Flash/Coprocessor/JoinInterpreterHelper.h | 11 + .../gtest_join_get_kind_and_build_index.cpp | 199 +++ dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp | 38 +- dbms/src/Flash/tests/gtest_spill_join.cpp | 155 ++ dbms/src/Functions/FunctionsJson.h | 8 + .../src/Functions/tests/gtest_json_object.cpp | 10 + .../src/Interpreters/CrossJoinProbeHelper.cpp | 198 +-- dbms/src/Interpreters/Join.cpp | 91 +- dbms/src/Interpreters/Join.h | 10 +- dbms/src/Interpreters/JoinHashMap.cpp | 55 +- dbms/src/Interpreters/JoinHashMap.h | 17 +- dbms/src/Interpreters/JoinPartition.cpp | 188 +-- dbms/src/Interpreters/JoinPartition.h | 8 +- dbms/src/Interpreters/JoinUtils.cpp | 37 + dbms/src/Interpreters/JoinUtils.h | 5 + .../Interpreters/NullAwareSemiJoinHelper.h | 15 +- dbms/src/Interpreters/ProbeProcessInfo.cpp | 33 +- dbms/src/Interpreters/ProbeProcessInfo.h | 19 +- .../Interpreters/tests/gtest_join_null_eq.cpp | 1245 +++++++++++++++++ dbms/src/TestUtils/ColumnsToTiPBExpr.h | 1 + dbms/src/TestUtils/mockExecutor.cpp | 4 +- dbms/src/TestUtils/mockExecutor.h | 3 +- .../TestUtils/tests/gtest_mock_executors.cpp | 25 + docs/note/nulleq_join.md | 601 ++++++++ 27 files changed, 2815 insertions(+), 253 deletions(-) create mode 100644 dbms/src/Interpreters/tests/gtest_join_null_eq.cpp create mode 100644 docs/note/nulleq_join.md diff --git a/dbms/src/Debug/MockExecutor/JoinBinder.cpp b/dbms/src/Debug/MockExecutor/JoinBinder.cpp index 0d89e7012ca..6256e223e1d 100644 --- a/dbms/src/Debug/MockExecutor/JoinBinder.cpp +++ b/dbms/src/Debug/MockExecutor/JoinBinder.cpp @@ -202,6 +202,7 @@ bool JoinBinder::toTiPBExecutor( join->set_join_exec_type(tipb::JoinExecType::TypeHashJoin); join->set_inner_idx(inner_index); join->set_is_null_aware_semi_join(is_null_aware_semi_join); + assert(is_null_eq.empty() || is_null_eq.size() == join_cols.size()); for (const auto & key : join_cols) { @@ -219,6 +220,9 @@ bool JoinBinder::toTiPBExecutor( collator_id); } + for (const auto flag : is_null_eq) + join->add_is_null_eq(flag != 0); + for (const auto & expr : left_conds) { tipb::Expr * cond = join->add_left_conditions(); @@ -341,6 +345,7 @@ ExecutorBinderPtr compileJoin( ExecutorBinderPtr right, tipb::JoinType tp, const ASTs & join_cols, + const std::vector & is_null_eq, const ASTs & left_conds, const ASTs & right_conds, const ASTs & other_conds, @@ -359,6 +364,7 @@ ExecutorBinderPtr compileJoin( output_schema, tp, join_cols, + is_null_eq, left_conds, right_conds, other_conds, @@ -407,6 +413,6 @@ ExecutorBinderPtr compileJoin(size_t & executor_index, ExecutorBinderPtr left, E join_cols.push_back(key); } } - return compileJoin(executor_index, left, right, tp, join_cols); + return compileJoin(executor_index, left, right, tp, join_cols, {}); } } // namespace DB::mock diff --git a/dbms/src/Debug/MockExecutor/JoinBinder.h b/dbms/src/Debug/MockExecutor/JoinBinder.h index 336183266a9..b6290a577be 100644 --- a/dbms/src/Debug/MockExecutor/JoinBinder.h +++ b/dbms/src/Debug/MockExecutor/JoinBinder.h @@ -29,6 +29,7 @@ class JoinBinder : public ExecutorBinder const DAGSchema & output_schema_, tipb::JoinType tp_, const ASTs & join_cols_, + const std::vector & is_null_eq_, const ASTs & l_conds, const ASTs & r_conds, const ASTs & o_conds, @@ -39,6 +40,7 @@ class JoinBinder : public ExecutorBinder : ExecutorBinder(index_, "Join_" + std::to_string(index_), output_schema_) , tp(tp_) , join_cols(join_cols_) + , is_null_eq(is_null_eq_) , left_conds(l_conds) , right_conds(r_conds) , other_conds(o_conds) @@ -77,6 +79,7 @@ class JoinBinder : public ExecutorBinder tipb::JoinType tp; const ASTs join_cols{}; + const std::vector is_null_eq{}; const ASTs left_conds{}; const ASTs right_conds{}; const ASTs other_conds{}; @@ -93,6 +96,7 @@ ExecutorBinderPtr compileJoin( ExecutorBinderPtr right, tipb::JoinType tp, const ASTs & join_cols, + const std::vector & is_null_eq = {}, const ASTs & left_conds = {}, const ASTs & right_conds = {}, const ASTs & other_conds = {}, diff --git a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp index af1efe3663d..719d9c1b18e 100644 --- a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp +++ b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp @@ -188,6 +188,19 @@ JoinKeyTypes getJoinKeyTypes(const tipb::Join & join) return join_key_types; } +std::vector getJoinKeyNullEqFlags(const tipb::Join & join) +{ + if (unlikely(join.is_null_eq_size() != 0 && join.is_null_eq_size() != join.left_join_keys_size())) + throw TiFlashException( + "size of join.is_null_eq does not match size of join.left_join_keys/right_join_keys", + Errors::Coprocessor::BadRequest); + + std::vector is_null_eq(join.left_join_keys_size(), 0); + for (int i = 0; i < join.is_null_eq_size(); ++i) + is_null_eq[i] = join.is_null_eq(i) ? 1 : 0; + return is_null_eq; +} + TiDB::TiDBCollators getJoinKeyCollators(const tipb::Join & join, const JoinKeyTypes & join_key_types, bool is_test) { TiDB::TiDBCollators collators; @@ -217,7 +230,18 @@ TiFlashJoin::TiFlashJoin(const tipb::Join & join_, bool is_test) // NOLINT(cppco : join(join_) , join_key_types(getJoinKeyTypes(join_)) , join_key_collators(getJoinKeyCollators(join_, join_key_types, is_test)) + , is_null_eq(getJoinKeyNullEqFlags(join_)) { + if (unlikely(join.is_null_aware_semi_join())) + { + for (auto flag : is_null_eq) + { + if (flag != 0) + throw TiFlashException( + "NullEQ join keys are incompatible with null-aware semi join", + Errors::Coprocessor::BadRequest); + } + } std::tie(kind, build_side_index) = getJoinKindAndBuildSideIndex(join); } @@ -412,6 +436,43 @@ std::tuple prepareJoin( return {chain.getLastActions(), std::move(key_names), std::move(original_key_names), std::move(filter_column_name)}; } +void alignNullEqKeyTypes( + const std::vector & is_null_eq, + const ExpressionActionsPtr & probe_prepare_actions, + Names & probe_key_names, + const ExpressionActionsPtr & build_prepare_actions, + Names & build_key_names) +{ + RUNTIME_CHECK(probe_key_names.size() == build_key_names.size()); + RUNTIME_CHECK(probe_key_names.size() == is_null_eq.size()); + + for (size_t i = 0; i < is_null_eq.size(); ++i) + { + if (is_null_eq[i] == 0) + continue; + + const auto & probe_type = probe_prepare_actions->getSampleBlock().getByName(probe_key_names[i]).type; + const auto & build_type = build_prepare_actions->getSampleBlock().getByName(build_key_names[i]).type; + if (probe_type->equals(*build_type)) + continue; + + RUNTIME_CHECK_MSG( + removeNullable(probe_type)->equals(*removeNullable(build_type)), + "NullEQ key type mismatch after prepareJoin is not a pure nullability mismatch: probe={} build={}", + probe_type->getName(), + build_type->getName()); + + if (!probe_type->isNullable()) + { + probe_prepare_actions->add(ExpressionAction::convertToNullable(probe_key_names[i])); + } + if (!build_type->isNullable()) + { + build_prepare_actions->add(ExpressionAction::convertToNullable(build_key_names[i])); + } + } +} + std::vector TiFlashJoin::genRuntimeFilterList( const Context & context, const NamesAndTypes & source_columns, @@ -452,6 +513,25 @@ std::vector TiFlashJoin::genRuntimeFilterList( return result; } +bool TiFlashJoin::shouldDisableRuntimeFilter( + const ExpressionActionsPtr & build_prepare_actions, + const Names & build_key_names) const +{ + RUNTIME_CHECK(build_prepare_actions != nullptr); + RUNTIME_CHECK(build_key_names.size() == is_null_eq.size()); + + const auto & sample_block = build_prepare_actions->getSampleBlock(); + for (size_t i = 0; i < is_null_eq.size(); ++i) + { + if (is_null_eq[i] == 0) + continue; + + if (sample_block.getByName(build_key_names[i]).type->isNullable()) + return true; + } + return false; +} + NamesAndTypes genDAGExpressionAnalyzerSourceColumns(Block block, const NamesAndTypes & tidb_schema) { /// generate source_columns that is used to compile tipb::Expr, the rule is columns in `tidb_schema` diff --git a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h index 7b2e324cd03..cf690c8387c 100644 --- a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h +++ b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h @@ -140,6 +140,7 @@ struct TiFlashJoin JoinKeyTypes join_key_types; TiDB::TiDBCollators join_key_collators; + std::vector is_null_eq; /// (cartesian) (anti) left outer semi join. bool isLeftOuterSemiFamily() const @@ -217,6 +218,9 @@ struct TiFlashJoin const NamesAndTypes & source_columns, const std::unordered_map & key_names_map, const LoggerPtr & log); + + bool shouldDisableRuntimeFilter(const ExpressionActionsPtr & build_prepare_actions, const Names & build_key_names) + const; }; /// @join_prepare_expr_actions: generates join key columns and join filter column @@ -230,6 +234,13 @@ std::tuple prepareJoin( const JoinKeyTypes & join_key_types, const google::protobuf::RepeatedPtrField & filters); +void alignNullEqKeyTypes( + const std::vector & is_null_eq, + const ExpressionActionsPtr & probe_prepare_actions, + Names & probe_key_names, + const ExpressionActionsPtr & build_prepare_actions, + Names & build_key_names); + /// generate source_columns that is used to compile tipb::Expr, the rule is columns in `tidb_schema` /// must be the first part of the source_columns NamesAndTypes genDAGExpressionAnalyzerSourceColumns(Block block, const NamesAndTypes & tidb_schema); diff --git a/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp b/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp index 30e6cdc927e..dd06c87033b 100644 --- a/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp +++ b/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include @@ -45,6 +46,18 @@ tipb::Join makeFullOuterJoinForSchemaTest(size_t inner_index) *join.add_right_join_keys() = makeJoinKeyWithFieldType(); return join; } + +tipb::Join makeNullAwareJoinWithNullEq() +{ + tipb::Join join; + join.set_join_type(tipb::JoinType::TypeAntiSemiJoin); + join.set_inner_idx(1); + join.set_is_null_aware_semi_join(true); + *join.add_left_join_keys() = makeJoinKeyWithFieldType(); + *join.add_right_join_keys() = makeJoinKeyWithFieldType(); + join.add_is_null_eq(true); + return join; +} } // namespace bool invalidParams(tipb::JoinType tipb_join_type, size_t inner_index, bool is_null_aware, size_t join_keys_size) @@ -73,6 +86,20 @@ String getErrorMessage(tipb::JoinType tipb_join_type, size_t inner_index, bool i } } +String getTiFlashJoinErrorMessage(const tipb::Join & join) +{ + try + { + JoinInterpreterHelper::TiFlashJoin tiflash_join(join, false); + static_cast(tiflash_join); + return ""; + } + catch (Exception & e) + { + return e.message(); + } +} + TEST(JoinKindAndBuildIndexTestRunner, TestNullAwareJoins) { auto result = JoinInterpreterHelper::getJoinKindAndBuildSideIndex(tipb::JoinType::TypeAntiSemiJoin, 1, true, 1); @@ -93,6 +120,178 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNullAwareJoins) ASSERT_TRUE(invalidParams(tipb::JoinType::TypeAntiLeftOuterSemiJoin, 0, true, 1)); } +TEST(JoinKindAndBuildIndexTestRunner, TestNullAwareJoinRejectsNullEqKeys) +{ + auto error_message = getTiFlashJoinErrorMessage(makeNullAwareJoinWithNullEq()); + ASSERT_FALSE(error_message.empty()); + ASSERT_NE(error_message.find("NullEQ"), String::npos); +} + +TEST(JoinKindAndBuildIndexTestRunner, TestNullEqAlignsMixedNullabilityKeySchema) +{ + try + { + auto int_type = std::make_shared(); + auto nullable_int_type = makeNullable(int_type); + auto context = TiFlashTestEnv::getContext(); + + ColumnWithTypeAndName probe_column{nullptr, int_type, "probe_k"}; + ColumnWithTypeAndName build_column{nullptr, nullable_int_type, "build_k"}; + + tipb::Join join; + join.set_join_type(tipb::JoinType::TypeInnerJoin); + join.set_inner_idx(1); + *join.add_left_join_keys() = columnToTiPBExpr(probe_column, 0); + *join.add_right_join_keys() = columnToTiPBExpr(build_column, 0); + join.add_is_null_eq(true); + + JoinInterpreterHelper::TiFlashJoin tiflash_join(join, true); + + NamesAndTypes probe_source_columns{{probe_column.name, probe_column.type}}; + NamesAndTypes build_source_columns{{build_column.name, build_column.type}}; + + auto [probe_prepare_actions, probe_key_names, original_probe_key_names, probe_filter_column_name] + = JoinInterpreterHelper::prepareJoin( + *context, + probe_source_columns, + tiflash_join.getProbeJoinKeys(), + tiflash_join.join_key_types, + tiflash_join.getProbeConditions()); + auto [build_prepare_actions, build_key_names, original_build_key_names, build_filter_column_name] + = JoinInterpreterHelper::prepareJoin( + *context, + build_source_columns, + tiflash_join.getBuildJoinKeys(), + tiflash_join.join_key_types, + tiflash_join.getBuildConditions()); + + ASSERT_FALSE(probe_prepare_actions->getSampleBlock().getByName(probe_key_names[0]).type->isNullable()); + ASSERT_TRUE(build_prepare_actions->getSampleBlock().getByName(build_key_names[0]).type->isNullable()); + + JoinInterpreterHelper::alignNullEqKeyTypes( + tiflash_join.is_null_eq, + probe_prepare_actions, + probe_key_names, + build_prepare_actions, + build_key_names); + + ASSERT_TRUE(probe_prepare_actions->getSampleBlock().getByName(probe_key_names[0]).type->isNullable()); + ASSERT_TRUE(build_prepare_actions->getSampleBlock().getByName(build_key_names[0]).type->isNullable()); + ASSERT_TRUE(probe_prepare_actions->getSampleBlock() + .getByName(probe_key_names[0]) + .type->equals(*build_prepare_actions->getSampleBlock().getByName(build_key_names[0]).type)); + } + catch (Exception & e) + { + FAIL() << e.message(); + } +} + +TEST(JoinKindAndBuildIndexTestRunner, TestNullableNullEqDisablesRuntimeFilter) +{ + try + { + auto int_type = std::make_shared(); + auto nullable_int_type = makeNullable(int_type); + auto context = TiFlashTestEnv::getContext(); + + ColumnWithTypeAndName probe_column{nullptr, int_type, "probe_k"}; + ColumnWithTypeAndName build_column{nullptr, nullable_int_type, "build_k"}; + + tipb::Join join; + join.set_join_type(tipb::JoinType::TypeInnerJoin); + join.set_inner_idx(1); + *join.add_left_join_keys() = columnToTiPBExpr(probe_column, 0); + *join.add_right_join_keys() = columnToTiPBExpr(build_column, 0); + join.add_is_null_eq(true); + + JoinInterpreterHelper::TiFlashJoin tiflash_join(join, true); + + NamesAndTypes probe_source_columns{{probe_column.name, probe_column.type}}; + NamesAndTypes build_source_columns{{build_column.name, build_column.type}}; + + auto [probe_prepare_actions, probe_key_names, original_probe_key_names, probe_filter_column_name] + = JoinInterpreterHelper::prepareJoin( + *context, + probe_source_columns, + tiflash_join.getProbeJoinKeys(), + tiflash_join.join_key_types, + tiflash_join.getProbeConditions()); + auto [build_prepare_actions, build_key_names, original_build_key_names, build_filter_column_name] + = JoinInterpreterHelper::prepareJoin( + *context, + build_source_columns, + tiflash_join.getBuildJoinKeys(), + tiflash_join.join_key_types, + tiflash_join.getBuildConditions()); + + JoinInterpreterHelper::alignNullEqKeyTypes( + tiflash_join.is_null_eq, + probe_prepare_actions, + probe_key_names, + build_prepare_actions, + build_key_names); + + ASSERT_TRUE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); + } + catch (Exception & e) + { + FAIL() << e.message(); + } +} + +TEST(JoinKindAndBuildIndexTestRunner, TestNonNullableNullEqKeepsRuntimeFilterEnabled) +{ + try + { + auto int_type = std::make_shared(); + auto context = TiFlashTestEnv::getContext(); + + ColumnWithTypeAndName probe_column{nullptr, int_type, "probe_k"}; + ColumnWithTypeAndName build_column{nullptr, int_type, "build_k"}; + + tipb::Join join; + join.set_join_type(tipb::JoinType::TypeInnerJoin); + join.set_inner_idx(1); + *join.add_left_join_keys() = columnToTiPBExpr(probe_column, 0); + *join.add_right_join_keys() = columnToTiPBExpr(build_column, 0); + join.add_is_null_eq(true); + + JoinInterpreterHelper::TiFlashJoin tiflash_join(join, true); + + NamesAndTypes probe_source_columns{{probe_column.name, probe_column.type}}; + NamesAndTypes build_source_columns{{build_column.name, build_column.type}}; + + auto [probe_prepare_actions, probe_key_names, original_probe_key_names, probe_filter_column_name] + = JoinInterpreterHelper::prepareJoin( + *context, + probe_source_columns, + tiflash_join.getProbeJoinKeys(), + tiflash_join.join_key_types, + tiflash_join.getProbeConditions()); + auto [build_prepare_actions, build_key_names, original_build_key_names, build_filter_column_name] + = JoinInterpreterHelper::prepareJoin( + *context, + build_source_columns, + tiflash_join.getBuildJoinKeys(), + tiflash_join.join_key_types, + tiflash_join.getBuildConditions()); + + JoinInterpreterHelper::alignNullEqKeyTypes( + tiflash_join.is_null_eq, + probe_prepare_actions, + probe_key_names, + build_prepare_actions, + build_key_names); + + ASSERT_FALSE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); + } + catch (Exception & e) + { + FAIL() << e.message(); + } +} + TEST(JoinKindAndBuildIndexTestRunner, TestCrossJoins) { /// Cross Inner Join, both sides supported diff --git a/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp b/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp index 779a5dd69f2..9378a79fe22 100644 --- a/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp +++ b/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp @@ -123,6 +123,13 @@ PhysicalPlanNodePtr PhysicalJoin::build( original_build_key_names, join_non_equal_conditions); + JoinInterpreterHelper::alignNullEqKeyTypes( + tiflash_join.is_null_eq, + probe_side_prepare_actions, + probe_key_names, + build_side_prepare_actions, + build_key_names); + const Settings & settings = context.getSettingsRef(); size_t max_bytes_before_external_join = settings.max_bytes_before_external_join; auto join_req_id = fmt::format("{}_{}", log->identifier(), executor_id); @@ -152,13 +159,6 @@ PhysicalPlanNodePtr PhysicalJoin::build( right_input_header, join_non_equal_conditions.other_cond_expr != nullptr); - assert(build_key_names.size() == original_build_key_names.size()); - std::unordered_map build_key_names_map; - for (size_t i = 0; i < original_build_key_names.size(); ++i) - { - build_key_names_map[original_build_key_names[i]] = build_key_names[i]; - } - // Conservative correctness guard: // If join key *protobuf field types* across sides are not compatible, skip runtime filter as early as possible // to avoid wrong filtering / wasted work. @@ -210,8 +210,13 @@ PhysicalPlanNodePtr PhysicalJoin::build( return true; }; - const bool enable_runtime_filter = is_join_key_field_type_compatible(); - if (!enable_runtime_filter && !join.runtime_filter_list().empty()) + bool enable_runtime_filter = is_join_key_field_type_compatible(); + if (tiflash_join.shouldDisableRuntimeFilter(build_side_prepare_actions, build_key_names)) + { + LOG_INFO(log, "Disable runtime filter because a nullable NullEQ build key is present"); + enable_runtime_filter = false; + } + else if (!enable_runtime_filter && !join.runtime_filter_list().empty()) { LOG_DEBUG( log, @@ -219,15 +224,24 @@ PhysicalPlanNodePtr PhysicalJoin::build( executor_id); } - auto runtime_filter_list = enable_runtime_filter - ? tiflash_join.genRuntimeFilterList(context, build_source_columns, build_key_names_map, log) - : std::vector{}; + std::vector runtime_filter_list; + if (enable_runtime_filter) + { + assert(build_key_names.size() == original_build_key_names.size()); + std::unordered_map build_key_names_map; + for (size_t i = 0; i < original_build_key_names.size(); ++i) + build_key_names_map[original_build_key_names[i]] = build_key_names[i]; + runtime_filter_list + = tiflash_join.genRuntimeFilterList(context, build_source_columns, build_key_names_map, log); + } + LOG_DEBUG(log, "before register runtime filter list, list size:{}", runtime_filter_list.size()); context.getDAGContext()->runtime_filter_mgr.registerRuntimeFilterList(runtime_filter_list); JoinPtr join_ptr = std::make_shared( probe_key_names, build_key_names, + tiflash_join.is_null_eq, tiflash_join.kind, join_req_id, fine_grained_shuffle.stream_count, diff --git a/dbms/src/Flash/tests/gtest_spill_join.cpp b/dbms/src/Flash/tests/gtest_spill_join.cpp index 5b9718096bf..181aa85787c 100644 --- a/dbms/src/Flash/tests/gtest_spill_join.cpp +++ b/dbms/src/Flash/tests/gtest_spill_join.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace DB { @@ -30,6 +31,50 @@ class SpillJoinTestRunner : public DB::tests::JoinTestRunner } }; +namespace +{ +constexpr auto * null_eq_join_db = "null_eq_join_test"; +constexpr auto * null_eq_left_table = "left_nullable_table"; +constexpr auto * null_eq_right_table = "right_nullable_table"; +constexpr auto * null_eq_right_exchange = "right_nullable_exchange"; +constexpr size_t null_eq_rows = 4096; +constexpr size_t null_eq_fgs_stream_count = 5; + +ColumnsWithTypeAndName makeNullEqJoinColumns(size_t rows, Int32 value_shift) +{ + std::vector keys; + std::vector values; + keys.reserve(rows); + values.reserve(rows); + for (size_t i = 0; i < rows; ++i) + { + if (i % 64 == 0) + keys.emplace_back(std::nullopt); + else + keys.emplace_back(static_cast(i)); + values.emplace_back(static_cast((i + value_shift) % 11)); + } + return {toNullableVec("k", keys), toNullableVec("v", values)}; +} + +void addNullEqJoinSources(MockDAGRequestContext & context) +{ + MockColumnInfoVec column_infos{{"k", TiDB::TP::TypeLong}, {"v", TiDB::TP::TypeLong}}; + MockColumnInfoVec partition_column_infos{{"k", TiDB::TP::TypeLong}}; + auto left_columns = makeNullEqJoinColumns(null_eq_rows, 1); + auto right_columns = makeNullEqJoinColumns(null_eq_rows, 5); + + context.addMockTable(null_eq_join_db, null_eq_left_table, column_infos, left_columns, 10); + context.addMockTable(null_eq_join_db, null_eq_right_table, column_infos, right_columns, 10); + context.addExchangeReceiver( + null_eq_right_exchange, + column_infos, + right_columns, + null_eq_fgs_stream_count, + partition_column_infos); +} +} // namespace + #define WRAP_FOR_SPILL_TEST_BEGIN \ std::vector pipeline_bools{false, true}; \ for (auto enable_pipeline : pipeline_bools) \ @@ -676,6 +721,116 @@ try } CATCH +TEST_F(SpillJoinTestRunner, FullOuterJoinWithOtherConditionNullEqSpill) +try +{ + constexpr UInt64 max_block_size = 800; + constexpr size_t original_max_streams = 20; + constexpr UInt64 max_bytes_before_external_join = 20000; + addNullEqJoinSources(context); + + auto left_key = fmt::format("{}.k", null_eq_left_table); + auto left_value = fmt::format("{}.v", null_eq_left_table); + auto right_key = fmt::format("{}.k", null_eq_right_table); + auto right_value = fmt::format("{}.v", null_eq_right_table); + + WRAP_FOR_SPILL_TEST_BEGIN + auto request = context.scan(null_eq_join_db, null_eq_left_table) + .join( + context.scan(null_eq_join_db, null_eq_right_table), + tipb::JoinType::TypeFullOuterJoin, + {col("k")}, + {}, + {}, + {lt(col(left_value), col(right_value))}, + {}, + 0, + false, + 1, + {1}) + .project({left_key, left_value, right_key, right_value}) + .build(context); + auto request_column_prune = context.scan(null_eq_join_db, null_eq_left_table) + .join( + context.scan(null_eq_join_db, null_eq_right_table), + tipb::JoinType::TypeFullOuterJoin, + {col("k")}, + {}, + {}, + {lt(col(left_value), col(right_value))}, + {}, + 0, + false, + 1, + {1}) + .aggregation({Count(lit(static_cast(1)))}, {}) + .build(context); + + context.context->setSetting("max_block_size", Field(static_cast(max_block_size))); + context.context->setSetting("max_bytes_before_external_join", Field(static_cast(0))); + auto ref_columns = executeStreams(request, original_max_streams); + + context.context->setSetting( + "max_bytes_before_external_join", + Field(static_cast(max_bytes_before_external_join))); + ASSERT_COLUMNS_EQ_UR(ref_columns, executeStreams(request, original_max_streams)); + ASSERT_COLUMNS_EQ_UR(genScalarCountResults(ref_columns), executeStreams(request_column_prune, 2)); + WRAP_FOR_SPILL_TEST_END +} +CATCH + +TEST_F(SpillJoinTestRunner, FineGrainedShuffleNullEqJoin) +try +{ + constexpr size_t original_max_streams = 20; + constexpr size_t original_max_streams_small = 4; + addNullEqJoinSources(context); + + auto left_key = fmt::format("{}.k", null_eq_left_table); + auto left_value = fmt::format("{}.v", null_eq_left_table); + auto right_value = fmt::format("{}.v", null_eq_right_table); + auto exchange_right_value = fmt::format("{}.v", null_eq_right_exchange); + + WRAP_FOR_SPILL_TEST_BEGIN + context.context->setSetting("max_bytes_before_external_join", Field(static_cast(0))); + auto reference = context.scan(null_eq_join_db, null_eq_left_table) + .join( + context.scan(null_eq_join_db, null_eq_right_table), + tipb::JoinType::TypeInnerJoin, + {col("k")}, + {}, + {}, + {}, + {}, + 0, + false, + 1, + {1}) + .project({left_key, left_value, right_value}) + .build(context); + auto ref_columns = executeStreams(reference, original_max_streams); + + auto request = context.scan(null_eq_join_db, null_eq_left_table) + .join( + context.receive(null_eq_right_exchange, null_eq_fgs_stream_count), + tipb::JoinType::TypeInnerJoin, + {col("k")}, + {}, + {}, + {}, + {}, + null_eq_fgs_stream_count, + false, + 1, + {1}) + .project({left_key, left_value, exchange_right_value}) + .build(context); + ASSERT_COLUMNS_EQ_UR(ref_columns, executeStreams(request, original_max_streams)); + ASSERT_COLUMNS_EQ_UR(ref_columns, executeStreams(request, original_max_streams_small)); + WRAP_FOR_SPILL_TEST_END +} +CATCH + #undef WRAP_FOR_SPILL_TEST_BEGIN #undef WRAP_FOR_SPILL_TEST_END diff --git a/dbms/src/Functions/FunctionsJson.h b/dbms/src/Functions/FunctionsJson.h index a2aa587714b..d9579efa5be 100644 --- a/dbms/src/Functions/FunctionsJson.h +++ b/dbms/src/Functions/FunctionsJson.h @@ -1060,6 +1060,14 @@ class FunctionJsonObject : public IFunction std::vector nullmaps; nullmaps.reserve(sources.size()); bool is_input_nullable = false; + for (const auto & source : sources) + { + if (source == nullptr) + { + is_input_nullable = true; + break; + } + } for (auto column_number : arguments) { const auto & col = block.getByPosition(column_number).column; diff --git a/dbms/src/Functions/tests/gtest_json_object.cpp b/dbms/src/Functions/tests/gtest_json_object.cpp index 2e56b109588..6eccdddf3e4 100644 --- a/dbms/src/Functions/tests/gtest_json_object.cpp +++ b/dbms/src/Functions/tests/gtest_json_object.cpp @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include #include #include #include @@ -98,6 +99,15 @@ try auto expect = createColumn>({R"({"dup": 2})", R"({"dup": 3})"}); ASSERT_COLUMN_EQ(expect, res); } + + { + ColumnsWithTypeAndName inputs{ + createConstColumn(rows_count, "a"), + createOnlyNullColumnConst(rows_count), + }; + auto res = executeFunctionWithCast({0, 1}, inputs); + ASSERT_COLUMN_EQ(createConstColumn>(rows_count, R"({"a": null})"), res); + } } CATCH diff --git a/dbms/src/Interpreters/CrossJoinProbeHelper.cpp b/dbms/src/Interpreters/CrossJoinProbeHelper.cpp index 73f5881a659..8c464432963 100644 --- a/dbms/src/Interpreters/CrossJoinProbeHelper.cpp +++ b/dbms/src/Interpreters/CrossJoinProbeHelper.cpp @@ -410,7 +410,7 @@ struct CrossJoinAdder } }; -template +template Block crossProbeBlockDeepCopyRightBlockImpl(ProbeProcessInfo & probe_process_info, const Blocks & right_blocks) { size_t num_existing_columns = probe_process_info.cross_join_data->left_column_index_in_left_block.size(); @@ -444,9 +444,9 @@ Block crossProbeBlockDeepCopyRightBlockImpl(ProbeProcessInfo & probe_process_inf for (; current_row < block_rows; ++current_row) { - if constexpr (has_null_map) + if constexpr (has_row_filter_map) { - if ((*probe_process_info.null_map)[current_row]) + if ((*probe_process_info.row_filter_map)[current_row]) { /// filter out by left_conditions, so just treated as not joined column block_full = CrossJoinAdder::addNotFound( @@ -501,7 +501,7 @@ Block crossProbeBlockDeepCopyRightBlockImpl(ProbeProcessInfo & probe_process_inf return probe_process_info.cross_join_data->result_block_schema.cloneWithColumns(std::move(dst_columns)); } -template +template std::pair crossProbeBlockShallowCopyRightBlockAddNotMatchedRows(ProbeProcessInfo & probe_process_info) { size_t num_existing_columns = probe_process_info.cross_join_data->left_column_index_in_left_block.size(); @@ -523,12 +523,12 @@ std::pair crossProbeBlockShallowCopyRightBlockAddNotMatchedRows(Pro .column.get(); } IColumn::Filter::value_type filter_column_value{}; - if constexpr (has_null_map) + if constexpr (has_row_filter_map) { - // todo use column->filter(null_map) to construct the result block in batch + // todo use column->filter(row_filter_map) to construct the result block in batch for (size_t i = 0; i < probe_process_info.block.rows(); ++i) { - if ((*probe_process_info.null_map)[i]) + if ((*probe_process_info.row_filter_map)[i]) { CrossJoinAdder::addNotFound( dst_columns, @@ -565,7 +565,7 @@ std::pair crossProbeBlockShallowCopyRightBlockAddNotMatchedRows(Pro return {probe_process_info.cross_join_data->result_block_schema.cloneWithColumns(std::move(dst_columns)), false}; } -template +template std::pair crossProbeBlockShallowCopyRightBlockImpl( ProbeProcessInfo & probe_process_info, const Blocks & right_blocks) @@ -574,11 +574,11 @@ std::pair crossProbeBlockShallowCopyRightBlockImpl( assert(probe_process_info.offsets_to_replicate != nullptr); size_t num_existing_columns = probe_process_info.cross_join_data->left_column_index_in_left_block.size(); - if constexpr (has_null_map) + if constexpr (has_row_filter_map) { /// skip filtered rows, the filtered rows will be handled at the end of this block while (probe_process_info.start_row < probe_process_info.block.rows() - && (*probe_process_info.null_map)[probe_process_info.start_row]) + && (*probe_process_info.row_filter_map)[probe_process_info.start_row]) { ++probe_process_info.start_row; } @@ -587,7 +587,7 @@ std::pair crossProbeBlockShallowCopyRightBlockImpl( if (probe_process_info.start_row == probe_process_info.block.rows()) { /// current probe block is done, collect un-matched rows - return crossProbeBlockShallowCopyRightBlockAddNotMatchedRows( + return crossProbeBlockShallowCopyRightBlockAddNotMatchedRows( probe_process_info); } assert(probe_process_info.cross_join_data->next_right_block_index < right_blocks.size()); @@ -645,45 +645,55 @@ Block crossProbeBlockDeepCopyRightBlock( { using enum ASTTableJoin::Strictness; using enum ASTTableJoin::Kind; -#define DISPATCH(HAS_NULL_MAP) \ - if (kind == Cross && strictness == All) \ - return crossProbeBlockDeepCopyRightBlockImpl(probe_process_info, right_blocks); \ - else if (kind == Cross_LeftOuter && strictness == All) \ - return crossProbeBlockDeepCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuter && strictness == Any) \ - return crossProbeBlockDeepCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_Semi && strictness == All) \ - return crossProbeBlockDeepCopyRightBlockImpl(probe_process_info, right_blocks); \ - else if (kind == Cross_Semi && strictness == Any) \ - return crossProbeBlockDeepCopyRightBlockImpl(probe_process_info, right_blocks); \ - else if (kind == Cross_Anti && strictness == All) \ - return crossProbeBlockDeepCopyRightBlockImpl(probe_process_info, right_blocks); \ - else if (kind == Cross_Anti && strictness == Any) \ - return crossProbeBlockDeepCopyRightBlockImpl(probe_process_info, right_blocks); \ - else if (kind == Cross_LeftOuterSemi && strictness == All) \ - return crossProbeBlockDeepCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuterSemi && strictness == Any) \ - return crossProbeBlockDeepCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuterAnti && strictness == All) \ - return crossProbeBlockDeepCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuterAnti && strictness == Any) \ - return crossProbeBlockDeepCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else \ +#define DISPATCH(HAS_ROW_FILTER_MAP) \ + if (kind == Cross && strictness == All) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuter && strictness == All) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuter && strictness == Any) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Semi && strictness == All) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Semi && strictness == Any) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Anti && strictness == All) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Anti && strictness == Any) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterSemi && strictness == All) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterSemi && strictness == Any) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterAnti && strictness == All) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterAnti && strictness == Any) \ + return crossProbeBlockDeepCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else \ throw Exception("Logical error: unknown combination of JOIN", ErrorCodes::LOGICAL_ERROR); - if (probe_process_info.null_map) + if (probe_process_info.row_filter_map) { DISPATCH(true) } @@ -702,53 +712,55 @@ std::pair crossProbeBlockShallowCopyRightBlock( { using enum ASTTableJoin::Strictness; using enum ASTTableJoin::Kind; -#define DISPATCH(HAS_NULL_MAP) \ - if (kind == Cross && strictness == All) \ - return crossProbeBlockShallowCopyRightBlockImpl(probe_process_info, right_blocks); \ - else if (kind == Cross_LeftOuter && strictness == All) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuter && strictness == Any) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_Semi && strictness == All) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_Semi && strictness == Any) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_Anti && strictness == All) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_Anti && strictness == Any) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuterSemi && strictness == All) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuterSemi && strictness == Any) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuterAnti && strictness == All) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else if (kind == Cross_LeftOuterAnti && strictness == Any) \ - return crossProbeBlockShallowCopyRightBlockImpl( \ - probe_process_info, \ - right_blocks); \ - else \ +#define DISPATCH(HAS_ROW_FILTER_MAP) \ + if (kind == Cross && strictness == All) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuter && strictness == All) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuter && strictness == Any) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Semi && strictness == All) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Semi && strictness == Any) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Anti && strictness == All) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_Anti && strictness == Any) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterSemi && strictness == All) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterSemi && strictness == Any) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterAnti && strictness == All) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else if (kind == Cross_LeftOuterAnti && strictness == Any) \ + return crossProbeBlockShallowCopyRightBlockImpl( \ + probe_process_info, \ + right_blocks); \ + else \ throw Exception("Logical error: unknown combination of JOIN", ErrorCodes::LOGICAL_ERROR); - if (probe_process_info.null_map) + if (probe_process_info.row_filter_map) { DISPATCH(true) } diff --git a/dbms/src/Interpreters/Join.cpp b/dbms/src/Interpreters/Join.cpp index a9dc50cce75..deb63b6afa8 100644 --- a/dbms/src/Interpreters/Join.cpp +++ b/dbms/src/Interpreters/Join.cpp @@ -56,22 +56,36 @@ extern const int TYPE_MISMATCH; namespace { -ColumnRawPtrs getKeyColumns(const Names & key_names, const Block & block) +ColumnRawPtrs getKeyColumns(const Names & key_names, const Block & block, const std::vector & is_null_eq = {}) { size_t keys_size = key_names.size(); + RUNTIME_CHECK(is_null_eq.empty() || is_null_eq.size() == keys_size); ColumnRawPtrs key_columns(keys_size); for (size_t i = 0; i < keys_size; ++i) { key_columns[i] = block.getByName(key_names[i]).column.get(); - /// We will join only keys, where all components are not NULL. - if (key_columns[i]->isColumnNullable()) + /// Ordinary '=' keys join only nested values where all components are not NULL. + /// NullEQ keys must keep their nullable wrapper so nullness can participate in key comparison. + if (key_columns[i]->isColumnNullable() && (is_null_eq.empty() || is_null_eq[i] == 0)) key_columns[i] = &static_cast(*key_columns[i]).getNestedColumn(); } return key_columns; } + +bool hasNullableNullEqKey(const Names & key_names, const Block & block, const std::vector & is_null_eq) +{ + RUNTIME_CHECK(key_names.size() == is_null_eq.size()); + for (size_t i = 0; i < key_names.size(); ++i) + { + if (is_null_eq[i] != 0 && block.getByName(key_names[i]).type->isNullable()) + return true; + } + return false; +} + size_t getRestoreJoinBuildConcurrency( size_t total_partitions, size_t spilled_partitions, @@ -101,6 +115,31 @@ size_t getRestoreJoinBuildConcurrency( } } +String formatNullEqFlags(const std::vector & flags) +{ + String result; + result.reserve(flags.size() * 2 + 2); + result += "["; + for (size_t i = 0; i < flags.size(); ++i) + { + if (i != 0) + result += ","; + result += flags[i] == 0 ? "0" : "1"; + } + result += "]"; + return result; +} + +bool hasNullEqKey(const std::vector & flags) +{ + for (auto flag : flags) + { + if (flag != 0) + return true; + } + return false; +} + } // namespace using PointerHelper = PointerTypeColumnHelper; @@ -116,6 +155,7 @@ const size_t MAX_RESTORE_ROUND_IN_GTEST = 2; Join::Join( const Names & key_names_left_, const Names & key_names_right_, + const std::vector & is_null_eq_, ASTTableJoin::Kind kind_, const String & req_id, size_t fine_grained_shuffle_count_, @@ -145,6 +185,7 @@ Join::Join( , may_probe_side_expanded_after_join(mayProbeSideExpandedAfterJoin(kind)) , key_names_left(key_names_left_) , key_names_right(key_names_right_) + , is_null_eq(is_null_eq_) , build_concurrency(0) , active_build_threads(0) , probe_concurrency(0) @@ -207,9 +248,11 @@ Join::Join( LOG_DEBUG( log, - "FineGrainedShuffle flag {}, stream count {}", + "FineGrainedShuffle flag {}, stream count {}, has_null_eq_key {}, is_null_eq {}", enable_fine_grained_shuffle, - fine_grained_shuffle_count); + fine_grained_shuffle_count, + hasNullEqKey(is_null_eq), + formatNullEqFlags(is_null_eq)); } void Join::meetError(const String & error_message_) @@ -362,6 +405,7 @@ std::shared_ptr Join::createRestoreJoin(size_t max_bytes_before_external_j auto ret = std::make_shared( key_names_left, key_names_right, + is_null_eq, kind, join_req_id, /// restore join never enable fine grained shuffle @@ -400,7 +444,16 @@ void Join::initBuild(const Block & sample_block, size_t build_concurrency_) if (unlikely(initialized)) throw Exception("Logical error: Join has been initialized", ErrorCodes::LOGICAL_ERROR); initialized = true; - join_map_method = chooseJoinMapMethod(getKeyColumns(key_names_right, sample_block), key_sizes, collators); + join_map_method = chooseJoinMapMethod( + getKeyColumns(key_names_right, sample_block, is_null_eq), + key_sizes, + collators, + is_null_eq); + if (hasNullableNullEqKey(key_names_right, sample_block, is_null_eq)) + { + if (join_map_method == JoinMapMethod::serialized) + LOG_DEBUG(log, "Use serialized join map method because nullable NullEQ keys do not fit packed fixed keys"); + } build_sample_block = sample_block; setBuildConcurrencyAndInitJoinPartition(build_concurrency_); hash_join_spill_context->init(build_concurrency); @@ -680,13 +733,12 @@ void Join::insertFromBlockInternal(Block * stored_block, size_t stream_index) } } - /// We will insert to the map only keys, where all components are not NULL. - ColumnPtr null_map_holder; - ConstNullMapPtr null_map{}; - extractNestedColumnsAndNullMap(key_columns, null_map_holder, null_map); - /// Reuse null_map to record the filtered rows, the rows contains NULL or does not - /// match the join filter will not insert to the maps - recordFilteredRows(block, non_equal_conditions.right_filter_column, null_map_holder, null_map); + /// Build a unified row filter map: ordinary '=' key NULLs and side-condition failures skip insertion, + /// while NullEQ key NULLs remain eligible for matching. + ColumnPtr row_filter_map_holder; + ConstNullMapPtr row_filter_map{}; + extractJoinKeyColumnsAndFilterNullMap(key_columns, is_null_eq, row_filter_map_holder, row_filter_map); + recordFilteredRows(block, non_equal_conditions.right_filter_column, row_filter_map_holder, row_filter_map); size_t size = stored_block->columns(); @@ -721,7 +773,7 @@ void Join::insertFromBlockInternal(Block * stored_block, size_t stream_index) key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, getBuildConcurrency(), enable_fine_grained_shuffle, @@ -1235,7 +1287,7 @@ Block Join::doJoinBlockHash(ProbeProcessInfo & probe_process_info, const JoinBui probe_process_info.hash_join_data->key_columns, key_sizes, added_columns, - probe_process_info.null_map, + probe_process_info.row_filter_map, current_offset, offsets_to_replicate, right_indexes, @@ -1309,8 +1361,9 @@ Block Join::doJoinBlockHash(ProbeProcessInfo & probe_process_info, const JoinBui // Return build table header for right semi/anti join block = right_sample_block; } - else if (kind == ASTTableJoin::Kind::RightOuter || kind == ASTTableJoin::Kind::Full) + else { + // flag_mapped_entry_helper_name is only used inside join, so it will not be returned to outside, we can safely remove it after setting hash table used flag. block.erase(flag_mapped_entry_helper_name); } } @@ -1348,6 +1401,7 @@ Block Join::joinBlockHash(ProbeProcessInfo & probe_process_info) const restore_config.restore_round}; probe_process_info.prepareForHashProbe( key_names_left, + is_null_eq, non_equal_conditions.left_filter_column, kind, strictness, @@ -1577,8 +1631,8 @@ Block Join::joinBlockNullAwareSemiImpl(ProbeProcessInfo & probe_process_info) co max_block_size, non_equal_conditions); NALeftSideInfo left_side_info( - probe_process_info.null_map, - probe_process_info.null_aware_join_data->filter_map, + probe_process_info.null_aware_join_data->key_null_map, + probe_process_info.row_filter_map, probe_process_info.null_aware_join_data->all_key_null_map); NARightSideInfo right_side_info( right_has_all_key_null_row.load(std::memory_order_relaxed), @@ -1670,6 +1724,7 @@ Block Join::joinBlockSemiImpl(ProbeProcessInfo & probe_process_info) const // probe a new block probe_process_info.prepareForHashProbe( key_names_left, + is_null_eq, non_equal_conditions.left_filter_column, kind, strictness, diff --git a/dbms/src/Interpreters/Join.h b/dbms/src/Interpreters/Join.h index 124f1b07e5b..dc85cbcbf9f 100644 --- a/dbms/src/Interpreters/Join.h +++ b/dbms/src/Interpreters/Join.h @@ -147,9 +147,8 @@ using OneTimeNotifyFuturePtr = std::shared_ptr; * * How Nullable keys are processed: * - * NULLs never join to anything, even to each other. - * During building of map, we just skip keys with NULL value of any component. - * During joining, we simply treat rows with any NULLs in key as non joined. + * For ordinary '=' keys, rows with NULL in any key component are filtered before build/probe. + * For NullEQ keys, NULL is allowed to participate in key comparison. * * Default values for outer joins (LEFT, RIGHT, FULL): * @@ -163,6 +162,7 @@ class Join Join( const Names & key_names_left_, const Names & key_names_right_, + const std::vector & is_null_eq_, ASTTableJoin::Kind kind_, const String & req_id, size_t fine_grained_shuffle_count_, @@ -246,6 +246,7 @@ class Join size_t getTotalBuildInputRows() const { return total_input_build_rows; } ASTTableJoin::Kind getKind() const { return kind; } + JoinMapMethod getJoinMapMethod() const { return join_map_method; } /// Inner/Semi cannot produce rows without build entries. RightSemi has no matched build rows to output. /// This is available after finalizeBuild and can be used to avoid reading the probe side. @@ -258,6 +259,7 @@ class Join } const Names & getLeftJoinKeys() const { return key_names_left; } + const std::vector & getNullEqFlags() const { return is_null_eq; } void setInitActiveBuildThreads() { @@ -356,6 +358,8 @@ class Join const Names key_names_left; /// Names of key columns (columns for equi-JOIN) in "right" table (in the order they appear in USING clause). const Names key_names_right; + /// Per join-key-pair null-safe-equal flags, aligned with key_names_left/key_names_right. + const std::vector is_null_eq; mutable std::mutex build_probe_mutex; diff --git a/dbms/src/Interpreters/JoinHashMap.cpp b/dbms/src/Interpreters/JoinHashMap.cpp index 153b562a0f0..9faffffa11a 100644 --- a/dbms/src/Interpreters/JoinHashMap.cpp +++ b/dbms/src/Interpreters/JoinHashMap.cpp @@ -14,10 +14,14 @@ #include #include +#include #include #include +#include #include +#include + namespace DB { namespace @@ -33,31 +37,68 @@ bool canAsColumnString(const IColumn * column) JoinMapMethod chooseJoinMapMethod( const ColumnRawPtrs & key_columns, Sizes & key_sizes, - const TiDB::TiDBCollators & collators) + const TiDB::TiDBCollators & collators, + const std::vector & is_null_eq) { const size_t keys_size = key_columns.size(); + RUNTIME_CHECK(is_null_eq.empty() || is_null_eq.size() == keys_size); if (keys_size == 0) return JoinMapMethod::CROSS; + ColumnRawPtrs nested_key_columns; + nested_key_columns.reserve(keys_size); + bool has_nullable_null_eq_key = false; + for (size_t j = 0; j < keys_size; ++j) + { + const auto * key_column = key_columns[j]; + if (const auto * nullable_column = typeid_cast(key_column)) + { + nested_key_columns.push_back(&nullable_column->getNestedColumn()); + has_nullable_null_eq_key = has_nullable_null_eq_key || (!is_null_eq.empty() && is_null_eq[j] != 0); + } + else + { + nested_key_columns.push_back(key_column); + } + } + bool all_fixed = true; size_t keys_bytes = 0; key_sizes.resize(keys_size); for (size_t j = 0; j < keys_size; ++j) { - if (!key_columns[j]->isFixedAndContiguous()) + if (!nested_key_columns[j]->isFixedAndContiguous()) { all_fixed = false; break; } - key_sizes[j] = key_columns[j]->sizeOfValueIfFixed(); + key_sizes[j] = nested_key_columns[j]->sizeOfValueIfFixed(); keys_bytes += key_sizes[j]; } + if (has_nullable_null_eq_key) + { + if (all_fixed) + { + constexpr auto nullable_keys128_null_map_bytes = std::tuple_size>::value; + constexpr auto nullable_keys256_null_map_bytes = std::tuple_size>::value; + if (keys_bytes > (std::numeric_limits::max() - nullable_keys256_null_map_bytes)) + throw Exception(ErrorCodes::LOGICAL_ERROR, "Join: keys sizes overflow"); + + if (nullable_keys128_null_map_bytes + keys_bytes <= sizeof(UInt128)) + return JoinMapMethod::nullable_keys128; + if (nullable_keys256_null_map_bytes + keys_bytes <= sizeof(UInt256)) + return JoinMapMethod::nullable_keys256; + } + + return JoinMapMethod::serialized; + } + /// If there is one numeric key that fits in 64 bits - if (keys_size == 1 && key_columns[0]->isNumeric()) + if (keys_size == 1 && nested_key_columns[0]->isNumeric()) { - size_t size_of_field = key_columns[0]->sizeOfValueIfFixed(); + size_t size_of_field = nested_key_columns[0]->sizeOfValueIfFixed(); if (size_of_field == 1) return JoinMapMethod::key8; if (size_of_field == 2) @@ -80,7 +121,7 @@ JoinMapMethod chooseJoinMapMethod( return JoinMapMethod::keys256; /// If there is single string key, use hash table of it's values. - if (keys_size == 1 && canAsColumnString(key_columns[0])) + if (keys_size == 1 && canAsColumnString(nested_key_columns[0])) { if (collators.empty() || !collators[0]) return JoinMapMethod::key_strbin; @@ -108,7 +149,7 @@ JoinMapMethod chooseJoinMapMethod( } } - if (keys_size == 1 && typeid_cast(key_columns[0])) + if (keys_size == 1 && typeid_cast(nested_key_columns[0])) return JoinMapMethod::key_fixed_string; /// Otherwise, use serialized values as the key. diff --git a/dbms/src/Interpreters/JoinHashMap.h b/dbms/src/Interpreters/JoinHashMap.h index d7a37355362..b5f0a975766 100644 --- a/dbms/src/Interpreters/JoinHashMap.h +++ b/dbms/src/Interpreters/JoinHashMap.h @@ -143,6 +143,8 @@ struct WithUsedFlag : Base M(key_fixed_string) \ M(keys128) \ M(keys256) \ + M(nullable_keys128) \ + M(nullable_keys256) \ M(serialized) enum class JoinMapMethod @@ -171,6 +173,8 @@ struct ConcurrentMapsTemplate using key_fixed_stringType = ConcurrentHashMapWithSavedHash; using keys128Type = ConcurrentHashMap>; using keys256Type = ConcurrentHashMap>; + using nullable_keys128Type = ConcurrentHashMap>; + using nullable_keys256Type = ConcurrentHashMap>; using serializedType = ConcurrentHashMapWithSavedHash; std::unique_ptr key8; @@ -183,6 +187,8 @@ struct ConcurrentMapsTemplate std::unique_ptr key_fixed_string; std::unique_ptr keys128; std::unique_ptr keys256; + std::unique_ptr nullable_keys128; + std::unique_ptr nullable_keys256; std::unique_ptr serialized; // TODO: add more cases like Aggregator }; @@ -201,6 +207,8 @@ struct MapsTemplate using key_fixed_stringType = HashMapWithSavedHash; using keys128Type = HashMap>; using keys256Type = HashMap>; + using nullable_keys128Type = HashMap>; + using nullable_keys256Type = HashMap>; using serializedType = HashMapWithSavedHash; std::unique_ptr key8; @@ -213,6 +221,8 @@ struct MapsTemplate std::unique_ptr key_fixed_string; std::unique_ptr keys128; std::unique_ptr keys256; + std::unique_ptr nullable_keys128; + std::unique_ptr nullable_keys256; std::unique_ptr serialized; // TODO: add more cases like Aggregator }; @@ -230,6 +240,8 @@ struct MapsAny using key_fixed_stringType = HashSetWithSavedHash; using keys128Type = HashSet>; using keys256Type = HashSet>; + using nullable_keys128Type = HashSet>; + using nullable_keys256Type = HashSet>; using serializedType = HashSetWithSavedHash; std::unique_ptr key8; @@ -242,6 +254,8 @@ struct MapsAny std::unique_ptr key_fixed_string; std::unique_ptr keys128; std::unique_ptr keys256; + std::unique_ptr nullable_keys128; + std::unique_ptr nullable_keys256; std::unique_ptr serialized; // TODO: add more cases like Aggregator }; @@ -257,5 +271,6 @@ using MapsAllFullWithRowFlag = MapsTemplate; // With fla JoinMapMethod chooseJoinMapMethod( const ColumnRawPtrs & key_columns, Sizes & key_sizes, - const TiDB::TiDBCollators & collators); + const TiDB::TiDBCollators & collators, + const std::vector & is_null_eq = {}); } // namespace DB diff --git a/dbms/src/Interpreters/JoinPartition.cpp b/dbms/src/Interpreters/JoinPartition.cpp index fe4048d7765..d5dbfa26919 100644 --- a/dbms/src/Interpreters/JoinPartition.cpp +++ b/dbms/src/Interpreters/JoinPartition.cpp @@ -440,6 +440,16 @@ struct KeyGetterForTypeImpl using Type = ColumnsHashing::HashMethodKeysFixed; }; template +struct KeyGetterForTypeImpl +{ + using Type = ColumnsHashing::HashMethodKeysFixed; +}; +template +struct KeyGetterForTypeImpl +{ + using Type = ColumnsHashing::HashMethodKeysFixed; +}; +template struct KeyGetterForTypeImpl { using Type = ColumnsHashing::HashMethodSerialized; @@ -527,7 +537,7 @@ template < ASTTableJoin::Strictness STRICTNESS, typename KeyGetter, typename Map, - bool has_null_map, + bool has_row_filter_map, bool need_record_not_insert_rows> void NO_INLINE insertBlockIntoMapTypeCase( JoinPartition & join_partition, @@ -536,7 +546,7 @@ void NO_INLINE insertBlockIntoMapTypeCase( const Sizes & key_sizes, const TiDB::TiDBCollators & collators, Block * stored_block, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, RowsNotInsertToMap * rows_not_inserted_to_map, size_t probe_cache_column_threshold) { @@ -550,13 +560,14 @@ void NO_INLINE insertBlockIntoMapTypeCase( bool null_need_materialize = isNullAwareSemiFamily(join_partition.getJoinKind()); for (size_t i = 0; i < rows; ++i) { - if constexpr (has_null_map) + if constexpr (has_row_filter_map) { - if ((*null_map)[i]) + if ((*row_filter_map)[i]) { if constexpr (need_record_not_insert_rows) { - /// for right/full out join or null-aware semi join, need to insert into rows_not_inserted_to_map + /// For right/full outer join or null-aware semi join, rows filtered before hash-map insertion + /// still need to be preserved in rows_not_inserted_to_map. rows_not_inserted_to_map->insertRow(stored_block, i, null_need_materialize, pool); } continue; @@ -579,7 +590,7 @@ template < ASTTableJoin::Strictness STRICTNESS, typename KeyGetter, typename Map, - bool has_null_map, + bool has_row_filter_map, bool need_record_not_insert_rows> void NO_INLINE insertBlockIntoMapsTypeCase( JoinPartitions & join_partitions, @@ -588,7 +599,7 @@ void NO_INLINE insertBlockIntoMapsTypeCase( const Sizes & key_sizes, const TiDB::TiDBCollators & collators, Block * stored_block, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, size_t stream_index, RowsNotInsertToMap * rows_not_inserted_to_map, size_t probe_cache_column_threshold) @@ -609,7 +620,7 @@ void NO_INLINE insertBlockIntoMapsTypeCase( /// 2. hash value is calculated twice, maybe we can refine the code to cache the hash value /// 3. extra memory to store the segment index info std::vector> segment_index_info; - if constexpr (has_null_map && need_record_not_insert_rows) + if constexpr (has_row_filter_map && need_record_not_insert_rows) { segment_index_info.resize(segment_size + 1); } @@ -624,9 +635,9 @@ void NO_INLINE insertBlockIntoMapsTypeCase( } for (size_t i = 0; i < rows; ++i) { - if constexpr (has_null_map) + if constexpr (has_row_filter_map) { - if ((*null_map)[i]) + if ((*row_filter_map)[i]) { if constexpr (need_record_not_insert_rows) segment_index_info.back().push_back(i); @@ -735,7 +746,7 @@ void insertBlockIntoMapsImplType( const Sizes & key_sizes, const TiDB::TiDBCollators & collators, Block * stored_block, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, size_t stream_index, size_t insert_concurrency, bool enable_fine_grained_shuffle, @@ -747,7 +758,7 @@ void insertBlockIntoMapsImplType( if (enable_join_spill) { /// case 1, join with spill support, the partition level lock is acquired in `Join::insertFromBlock` - if (null_map) + if (row_filter_map) { if (rows_not_inserted_to_map) insertBlockIntoMapTypeCase( @@ -757,7 +768,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, rows_not_inserted_to_map, probe_cache_column_threshold); else @@ -768,7 +779,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, nullptr, probe_cache_column_threshold); } @@ -781,7 +792,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, nullptr, probe_cache_column_threshold); } @@ -790,7 +801,7 @@ void insertBlockIntoMapsImplType( else if (enable_fine_grained_shuffle) { /// case 2, join with fine_grained_shuffle, no need to acquire any lock - if (null_map) + if (row_filter_map) { if (rows_not_inserted_to_map) insertBlockIntoMapTypeCase( @@ -800,7 +811,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, rows_not_inserted_to_map, probe_cache_column_threshold); else @@ -811,7 +822,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, nullptr, probe_cache_column_threshold); } @@ -824,7 +835,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, nullptr, probe_cache_column_threshold); } @@ -832,7 +843,7 @@ void insertBlockIntoMapsImplType( else if (insert_concurrency > 1) { /// case 3, normal join with concurrency > 1, will acquire lock in `insertBlockIntoMapsTypeCase` - if (null_map) + if (row_filter_map) { if (rows_not_inserted_to_map) insertBlockIntoMapsTypeCase( @@ -842,7 +853,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, rows_not_inserted_to_map, probe_cache_column_threshold); @@ -854,7 +865,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, nullptr, probe_cache_column_threshold); @@ -868,7 +879,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, nullptr, probe_cache_column_threshold); @@ -878,7 +889,7 @@ void insertBlockIntoMapsImplType( { /// case 4, normal join with concurrency == 1, no need to acquire any lock RUNTIME_CHECK(stream_index == 0); - if (null_map) + if (row_filter_map) { if (rows_not_inserted_to_map) insertBlockIntoMapTypeCase( @@ -888,7 +899,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, rows_not_inserted_to_map, probe_cache_column_threshold); else @@ -899,7 +910,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, nullptr, probe_cache_column_threshold); } @@ -912,7 +923,7 @@ void insertBlockIntoMapsImplType( key_sizes, collators, stored_block, - null_map, + row_filter_map, nullptr, probe_cache_column_threshold); } @@ -927,7 +938,7 @@ void insertBlockIntoMapsImpl( const Sizes & key_sizes, const TiDB::TiDBCollators & collators, Block * stored_block, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, size_t stream_index, size_t insert_concurrency, bool enable_fine_grained_shuffle, @@ -953,7 +964,7 @@ void insertBlockIntoMapsImpl( key_sizes, \ collators, \ stored_block, \ - null_map, \ + row_filter_map, \ stream_index, \ insert_concurrency, \ enable_fine_grained_shuffle, \ @@ -1001,7 +1012,7 @@ void JoinPartition::insertBlockIntoMaps( const std::vector & key_sizes, const TiDB::TiDBCollators & collators, Block * stored_block, - ConstNullMapPtr & null_map, + ConstNullMapPtr & row_filter_map, size_t stream_index, size_t insert_concurrency, bool enable_fine_grained_shuffle, @@ -1021,7 +1032,7 @@ void JoinPartition::insertBlockIntoMaps( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, insert_concurrency, enable_fine_grained_shuffle, @@ -1035,7 +1046,7 @@ void JoinPartition::insertBlockIntoMaps( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, insert_concurrency, enable_fine_grained_shuffle, @@ -1052,7 +1063,7 @@ void JoinPartition::insertBlockIntoMaps( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, insert_concurrency, enable_fine_grained_shuffle, @@ -1066,7 +1077,7 @@ void JoinPartition::insertBlockIntoMaps( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, insert_concurrency, enable_fine_grained_shuffle, @@ -1083,7 +1094,7 @@ void JoinPartition::insertBlockIntoMaps( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, insert_concurrency, enable_fine_grained_shuffle, @@ -1100,7 +1111,7 @@ void JoinPartition::insertBlockIntoMaps( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, insert_concurrency, enable_fine_grained_shuffle, @@ -1114,7 +1125,7 @@ void JoinPartition::insertBlockIntoMaps( key_sizes, collators, stored_block, - null_map, + row_filter_map, stream_index, insert_concurrency, enable_fine_grained_shuffle, @@ -1482,7 +1493,7 @@ template < ASTTableJoin::Strictness STRICTNESS, typename KeyGetter, typename Map, - bool has_null_map, + bool has_row_filter_map, bool row_flagged_map> void NO_INLINE probeBlockImplTypeCase( const JoinPartitions & join_partitions, @@ -1490,7 +1501,7 @@ void NO_INLINE probeBlockImplTypeCase( const ColumnRawPtrs & key_columns, const Sizes & key_sizes, MutableColumns & added_columns, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, IColumn::Offset & current_offset, std::unique_ptr & offsets_to_replicate, const std::vector & right_indexes, @@ -1533,7 +1544,7 @@ void NO_INLINE probeBlockImplTypeCase( bool block_full = false; for (i = probe_process_info.start_row; i < rows; ++i) { - if (has_null_map && (*null_map)[i]) + if (has_row_filter_map && (*row_filter_map)[i]) { if constexpr (row_flagged_map) { @@ -1704,7 +1715,7 @@ void probeBlockImplType( const ColumnRawPtrs & key_columns, const Sizes & key_sizes, MutableColumns & added_columns, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, IColumn::Offset & current_offset, std::unique_ptr & offsets_to_replicate, const std::vector & right_indexes, @@ -1712,22 +1723,22 @@ void probeBlockImplType( const JoinBuildInfo & join_build_info, ProbeProcessInfo & probe_process_info) { -#define CALL(has_null_map) \ - probeBlockImplTypeCase( \ - join_partitions, \ - rows, \ - key_columns, \ - key_sizes, \ - added_columns, \ - null_map, \ - current_offset, \ - offsets_to_replicate, \ - right_indexes, \ - collators, \ - join_build_info, \ +#define CALL(has_row_filter_map) \ + probeBlockImplTypeCase( \ + join_partitions, \ + rows, \ + key_columns, \ + key_sizes, \ + added_columns, \ + row_filter_map, \ + current_offset, \ + offsets_to_replicate, \ + right_indexes, \ + collators, \ + join_build_info, \ probe_process_info); - if (null_map) + if (row_filter_map) { CALL(true); } @@ -1743,8 +1754,8 @@ template < ASTTableJoin::Strictness STRICTNESS, typename KeyGetter, typename Map, - bool has_null_map, - bool has_filter_map> + bool has_key_null_map, + bool has_row_filter_map> std::pair>, std::list *>> NO_INLINE probeBlockNullAwareSemiInternal( const JoinPartitions & join_partitions, @@ -1774,9 +1785,9 @@ probeBlockNullAwareSemiInternal( /// the result if it's not left outer semi join. for (size_t i = 0; i < rows; ++i) { - if constexpr (has_filter_map) + if constexpr (has_row_filter_map) { - if ((*left_side_info.filter_map)[i]) + if ((*left_side_info.row_filter_map)[i]) { /// Filter out by left_conditions so the result set is empty. res.emplace_back(i, NASemiJoinStep::DONE, nullptr); @@ -1792,9 +1803,9 @@ probeBlockNullAwareSemiInternal( res.back().template setResult(); continue; } - if constexpr (has_null_map) + if constexpr (has_key_null_map) { - if ((*left_side_info.null_map)[i]) + if ((*left_side_info.key_null_map)[i]) { /// some key is null if constexpr (STRICTNESS == ASTTableJoin::Strictness::Any) @@ -1902,19 +1913,19 @@ std::pair>, std::list( \ - join_partitions, \ - rows, \ - key_columns, \ - key_sizes, \ - collators, \ - left_side_info, \ +#define CALL(has_key_null_map, has_row_filter_map) \ + return probeBlockNullAwareSemiInternal( \ + join_partitions, \ + rows, \ + key_columns, \ + key_sizes, \ + collators, \ + left_side_info, \ right_side_info); - if (left_side_info.null_map) + if (left_side_info.key_null_map) { - if (left_side_info.filter_map) + if (left_side_info.row_filter_map) { CALL(true, true); } @@ -1925,7 +1936,7 @@ std::pair>, std::list + bool has_row_filter_map> std::pair>, std::list *>> NO_INLINE probeBlockSemiInternal( const JoinPartitions & join_partitions, @@ -1991,9 +2002,10 @@ probeBlockSemiInternal( const auto & build_hash_data = probe_process_info.hash_join_data->hash_data->getData(); for (size_t i = 0; i < rows; ++i) { - if constexpr (has_null_map) + if constexpr (has_row_filter_map) { - /// If key columns have null map, it means these key columns do not come from IN. + /// row_filter_map means these rows should not enter regular hash probing. + /// For semi-family joins, this covers ordinary '=' key NULLs and side-condition failures. /// For example: /// SQL: select * from t1 where t1.a not in (select t2.a from t2 where t1.b = t2.b) /// t1.a or t2.a can be null. @@ -2001,7 +2013,7 @@ probeBlockSemiInternal( /// and t1.a = t2.a as other condition from IN. /// SQL: select * from t1 where t1.a not in (select t2.a from t2), t1.a or t2.a can be null. /// If this SQL does not have t1.b = t2.b, null-aware anti semi join will be used. - if ((*probe_process_info.null_map)[i]) + if ((*probe_process_info.row_filter_map)[i]) { if constexpr (STRICTNESS == ASTTableJoin::Strictness::Any) { @@ -2102,16 +2114,16 @@ std::pair>, std::list( \ - join_partitions, \ - rows, \ - key_sizes, \ - collators, \ - join_build_info, \ +#define CALL(has_row_filter_map) \ + return probeBlockSemiInternal( \ + join_partitions, \ + rows, \ + key_sizes, \ + collators, \ + join_build_info, \ probe_process_info); - if (probe_process_info.null_map) + if (probe_process_info.row_filter_map) { CALL(true); } @@ -2130,7 +2142,7 @@ void JoinPartition::probeBlock( const ColumnRawPtrs & key_columns, const std::vector & key_sizes, MutableColumns & added_columns, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, IColumn::Offset & current_offset, std::unique_ptr & offsets_to_replicate, const std::vector & right_indexes, @@ -2159,7 +2171,7 @@ void JoinPartition::probeBlock( key_columns, \ key_sizes, \ added_columns, \ - null_map, \ + row_filter_map, \ current_offset, \ offsets_to_replicate, \ right_indexes, \ @@ -2199,7 +2211,7 @@ void JoinPartition::probeBlockImpl( const ColumnRawPtrs & key_columns, const std::vector & key_sizes, MutableColumns & added_columns, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, IColumn::Offset & current_offset, std::unique_ptr & offsets_to_replicate, const std::vector & right_indexes, @@ -2224,7 +2236,7 @@ void JoinPartition::probeBlockImpl( key_columns, \ key_sizes, \ added_columns, \ - null_map, \ + row_filter_map, \ current_offset, \ offsets_to_replicate, \ right_indexes, \ diff --git a/dbms/src/Interpreters/JoinPartition.h b/dbms/src/Interpreters/JoinPartition.h index 899604565d1..9deb3208bb7 100644 --- a/dbms/src/Interpreters/JoinPartition.h +++ b/dbms/src/Interpreters/JoinPartition.h @@ -127,7 +127,7 @@ class JoinPartition return rows_not_inserted_to_map.get(); } return nullptr; - }; + } Blocks trySpillProbePartition() { std::unique_lock lock(partition_mutex); @@ -176,7 +176,7 @@ class JoinPartition const std::vector & key_sizes, const TiDB::TiDBCollators & collators, Block * stored_block, - ConstNullMapPtr & null_map, + ConstNullMapPtr & row_filter_map, size_t stream_index, size_t insert_concurrency, bool enable_fine_grained_shuffle, @@ -190,7 +190,7 @@ class JoinPartition const ColumnRawPtrs & key_columns, const std::vector & key_sizes, MutableColumns & added_columns, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, IColumn::Offset & current_offset, std::unique_ptr & offsets_to_replicate, const std::vector & right_indexes, @@ -205,7 +205,7 @@ class JoinPartition const ColumnRawPtrs & key_columns, const std::vector & key_sizes, MutableColumns & added_columns, - ConstNullMapPtr null_map, + ConstNullMapPtr row_filter_map, IColumn::Offset & current_offset, std::unique_ptr & offsets_to_replicate, const std::vector & right_indexes, diff --git a/dbms/src/Interpreters/JoinUtils.cpp b/dbms/src/Interpreters/JoinUtils.cpp index c3b02cf7bba..5c93c2adf57 100644 --- a/dbms/src/Interpreters/JoinUtils.cpp +++ b/dbms/src/Interpreters/JoinUtils.cpp @@ -40,6 +40,43 @@ ColumnRawPtrs extractAndMaterializeKeyColumns( return key_columns; } +void extractJoinKeyColumnsAndFilterNullMap( + ColumnRawPtrs & key_columns, + const std::vector & is_null_eq, + ColumnPtr & null_map_holder, + ConstNullMapPtr & null_map) +{ + RUNTIME_CHECK(key_columns.size() == is_null_eq.size()); + + for (size_t i = 0; i < key_columns.size(); ++i) + { + auto & column = key_columns[i]; + if (!column->isColumnNullable() || is_null_eq[i] != 0) + continue; + + const auto & column_nullable = static_cast(*column); + column = &column_nullable.getNestedColumn(); + + if (!null_map_holder) + { + null_map_holder = column_nullable.getNullMapColumnPtr(); + } + else + { + MutableColumnPtr mutable_null_map_holder = (*std::move(null_map_holder)).mutate(); + + PaddedPODArray & mutable_null_map = static_cast(*mutable_null_map_holder).getData(); + const PaddedPODArray & other_null_map = column_nullable.getNullMapData(); + for (size_t row = 0, size = mutable_null_map.size(); row < size; ++row) + mutable_null_map[row] |= other_null_map[row]; + + null_map_holder = std::move(mutable_null_map_holder); + } + } + + null_map = null_map_holder ? &static_cast(*null_map_holder).getData() : nullptr; +} + void recordFilteredRows( const Block & block, const String & filter_column, diff --git a/dbms/src/Interpreters/JoinUtils.h b/dbms/src/Interpreters/JoinUtils.h index 8a6ba93a285..ce7026691dc 100644 --- a/dbms/src/Interpreters/JoinUtils.h +++ b/dbms/src/Interpreters/JoinUtils.h @@ -142,6 +142,11 @@ ColumnRawPtrs extractAndMaterializeKeyColumns( const Block & block, Columns & materialized_columns, const Strings & key_columns_names); +void extractJoinKeyColumnsAndFilterNullMap( + ColumnRawPtrs & key_columns, + const std::vector & is_null_eq, + ColumnPtr & null_map_holder, + ConstNullMapPtr & null_map); void recordFilteredRows( const Block & block, const String & filter_column, diff --git a/dbms/src/Interpreters/NullAwareSemiJoinHelper.h b/dbms/src/Interpreters/NullAwareSemiJoinHelper.h index 530a68df800..e37fbe6a305 100644 --- a/dbms/src/Interpreters/NullAwareSemiJoinHelper.h +++ b/dbms/src/Interpreters/NullAwareSemiJoinHelper.h @@ -63,15 +63,18 @@ struct NARightSideInfo struct NALeftSideInfo { NALeftSideInfo( - const ConstNullMapPtr & null_map_, - const ConstNullMapPtr & filter_map_, + const ConstNullMapPtr & key_null_map_, + const ConstNullMapPtr & row_filter_map_, const ConstNullMapPtr & all_key_null_map_) - : null_map(null_map_) - , filter_map(filter_map_) + : key_null_map(key_null_map_) + , row_filter_map(row_filter_map_) , all_key_null_map(all_key_null_map_) {} - const ConstNullMapPtr & null_map; - const ConstNullMapPtr & filter_map; + /// Rows whose null-aware join key contains at least one NULL. + const ConstNullMapPtr & key_null_map; + /// Rows filtered out by side conditions before null-aware probing. + const ConstNullMapPtr & row_filter_map; + /// Rows whose null-aware join keys are all NULL. const ConstNullMapPtr & all_key_null_map; }; diff --git a/dbms/src/Interpreters/ProbeProcessInfo.cpp b/dbms/src/Interpreters/ProbeProcessInfo.cpp index 5a43dad781b..6e626c6e9aa 100644 --- a/dbms/src/Interpreters/ProbeProcessInfo.cpp +++ b/dbms/src/Interpreters/ProbeProcessInfo.cpp @@ -32,8 +32,8 @@ void ProbeProcessInfo::resetBlock(Block && block_, size_t partition_index_) // min_result_block_size is used to avoid generating too many small block, use 50% of the block size as the default value min_result_block_size = std::max(1, (std::min(block.rows(), max_block_size) + 1) / 2); prepare_for_probe_done = false; - null_map = nullptr; - null_map_holder = nullptr; + row_filter_map = nullptr; + row_filter_map_holder = nullptr; filter.reset(); offsets_to_replicate.reset(); if (hash_join_data) @@ -46,6 +46,7 @@ void ProbeProcessInfo::resetBlock(Block && block_, size_t partition_index_) void ProbeProcessInfo::prepareForHashProbe( const Names & key_names, + const std::vector & is_null_eq, const String & filter_column, ASTTableJoin::Kind kind, ASTTableJoin::Strictness strictness, @@ -61,11 +62,14 @@ void ProbeProcessInfo::prepareForHashProbe( /// Note: this variable can't be removed because it will take smart pointers' lifecycle to the end of this function. hash_join_data->key_columns = extractAndMaterializeKeyColumns(block, hash_join_data->materialized_columns, key_names); - /// Keys with NULL value in any column won't join to anything. - extractNestedColumnsAndNullMap(hash_join_data->key_columns, null_map_holder, null_map); - /// reuse null_map to record the filtered rows, the rows contains NULL or does not - /// match the join filter won't join to anything - recordFilteredRows(block, filter_column, null_map_holder, null_map); + /// Build a unified row filter map: ordinary '=' key NULLs and side-condition failures skip probing, + /// while NullEQ key NULLs remain probeable. + extractJoinKeyColumnsAndFilterNullMap( + hash_join_data->key_columns, + is_null_eq, + row_filter_map_holder, + row_filter_map); + recordFilteredRows(block, filter_column, row_filter_map_holder, row_filter_map); size_t existing_columns = block.columns(); /** If you use FULL or RIGHT JOIN, then the columns from the "left" table must be materialized. @@ -121,7 +125,7 @@ void ProbeProcessInfo::prepareForCrossProbe( cross_join_data->cross_probe_mode = cross_probe_mode_; cross_join_data->right_block_size = right_block_size_; - recordFilteredRows(block, filter_column, null_map_holder, null_map); + recordFilteredRows(block, filter_column, row_filter_map_holder, row_filter_map); if (kind == ASTTableJoin::Kind::Cross_Anti && strictness == ASTTableJoin::Strictness::All) /// `CrossJoinAdder` will skip the matched rows directly, so filter is not needed filter = std::make_unique(block.rows()); @@ -157,8 +161,8 @@ void ProbeProcessInfo::prepareForCrossProbe( } } } - if (cross_join_data->cross_probe_mode == CrossProbeMode::SHALLOW_COPY_RIGHT_BLOCK && null_map != nullptr) - cross_join_data->row_num_filtered_by_left_condition = countBytesInFilter(*null_map); + if (cross_join_data->cross_probe_mode == CrossProbeMode::SHALLOW_COPY_RIGHT_BLOCK && row_filter_map != nullptr) + cross_join_data->row_num_filtered_by_left_condition = countBytesInFilter(*row_filter_map); prepare_for_probe_done = true; } @@ -179,9 +183,14 @@ void ProbeProcessInfo::prepareForNullAware(const Names & key_names, const String null_aware_join_data->all_key_null_map_holder, null_aware_join_data->all_key_null_map); - extractNestedColumnsAndNullMap(null_aware_join_data->key_columns, null_map_holder, null_map); + extractNestedColumnsAndNullMap( + null_aware_join_data->key_columns, + null_aware_join_data->key_null_map_holder, + null_aware_join_data->key_null_map); - recordFilteredRows(block, filter_column, null_aware_join_data->filter_map_holder, null_aware_join_data->filter_map); + // Reuse the generic probe-side row filter map, but for null-aware join it only records + // rows filtered out by side conditions. Key-null rows are tracked separately in key_null_map. + recordFilteredRows(block, filter_column, row_filter_map_holder, row_filter_map); prepare_for_probe_done = true; } diff --git a/dbms/src/Interpreters/ProbeProcessInfo.h b/dbms/src/Interpreters/ProbeProcessInfo.h index f23496257df..a9d5c4a0d92 100644 --- a/dbms/src/Interpreters/ProbeProcessInfo.h +++ b/dbms/src/Interpreters/ProbeProcessInfo.h @@ -76,16 +76,18 @@ struct NullAwareJoinProbeProcessData { Columns materialized_columns; ColumnRawPtrs key_columns; - ColumnPtr filter_map_holder = nullptr; - ConstNullMapPtr filter_map = nullptr; + /// Rows where any null-aware join key is NULL. + ColumnPtr key_null_map_holder = nullptr; + ConstNullMapPtr key_null_map = nullptr; + /// Rows where all null-aware join keys are NULL. ColumnPtr all_key_null_map_holder = nullptr; ConstNullMapPtr all_key_null_map = nullptr; void reset() { key_columns.clear(); materialized_columns.clear(); - filter_map_holder = nullptr; - filter_map = nullptr; + key_null_map_holder = nullptr; + key_null_map = nullptr; all_key_null_map_holder = nullptr; all_key_null_map = nullptr; } @@ -104,8 +106,12 @@ struct ProbeProcessInfo /// these should be inited before probe each block bool prepare_for_probe_done = false; - ColumnPtr null_map_holder = nullptr; - ConstNullMapPtr null_map = nullptr; + /// Unified probe-side row filter map. + /// For regular hash/cross join, it contains ordinary '=' key NULLs plus side-condition failures. + /// For null-aware join, it contains side-condition failures only; key-null rows stay in + /// null_aware_join_data->key_null_map. + ColumnPtr row_filter_map_holder = nullptr; + ConstNullMapPtr row_filter_map = nullptr; /// Used with ANY INNER ANTI JOIN std::unique_ptr filter = nullptr; /// Used with ALL ... JOIN @@ -174,6 +180,7 @@ struct ProbeProcessInfo void prepareForHashProbe( const Names & key_names, + const std::vector & is_null_eq, const String & filter_column, ASTTableJoin::Kind kind, ASTTableJoin::Strictness strictness, diff --git a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp new file mode 100644 index 00000000000..f3b2bc1b8ce --- /dev/null +++ b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp @@ -0,0 +1,1245 @@ +// Copyright 2023 PingCAP, Inc. +// +// Licensed 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace DB::tests +{ +namespace +{ +constexpr auto * test_key_name = "k"; +constexpr auto * probe_eq_key_name = "eq_k"; +constexpr auto * probe_null_eq_key_name = "null_eq_k"; +constexpr auto * probe_filter_name = "filter"; +constexpr auto * outer_probe_key_name = "probe_k"; +constexpr auto * outer_probe_value_name = "probe_v"; +constexpr auto * outer_probe_filter_name = "probe_filter"; +constexpr auto * outer_build_key_name = "build_k"; +constexpr auto * outer_build_value_name = "build_v"; +constexpr auto * outer_build_filter_name = "build_filter"; +constexpr auto * mixed_probe_key1_name = "probe_k1"; +constexpr auto * mixed_probe_key2_name = "probe_k2"; +constexpr auto * mixed_probe_value_name = "probe_multi_v"; +constexpr auto * mixed_build_key1_name = "build_k1"; +constexpr auto * mixed_build_key2_name = "build_k2"; +constexpr auto * mixed_build_value_name = "build_multi_v"; +constexpr auto * full_other_cond_name = "full_other_cond"; +constexpr auto * full_flag_helper_name = "__full_flag_helper"; + +Block makeOuterProbeSampleBlock(const DataTypePtr & key_type, bool include_filter); +Block makeOuterBuildSampleBlock(const DataTypePtr & key_type, bool include_filter); +void prepareAndFinalizeMixedJoin(const JoinPtr & join, const DataTypePtr & key_type); + +void ensureFunctionsRegistered() +{ + static std::once_flag once; + std::call_once(once, [] { + try + { + registerFunctions(); + } + catch (DB::Exception &) + { + // Another test suite may have already registered the functions. + } + }); +} + +Block makeSampleBlock(const DataTypePtr & key_type) +{ + return Block{{key_type->createColumn(), key_type, test_key_name}}; +} + +JoinPtr makeTestJoin(const DataTypePtr & key_type, const std::vector & is_null_eq) +{ + SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); + SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); + return std::make_shared( + Names{test_key_name}, + Names{test_key_name}, + is_null_eq, + ASTTableJoin::Kind::Inner, + "join_null_eq_test", + 0, + 0, + build_spill_config, + probe_spill_config, + RestoreConfig{1, 0, 0}, + NamesAndTypes{{test_key_name, key_type}}, + RegisterOperatorSpillContext{}, + nullptr, + TiDB::TiDBCollators{}, + JoinNonEqualConditions{}, + 1024, + 0, + "", + "", + 0, + true); +} + +JoinNonEqualConditions makeFullJoinOtherCondition() +{ + ensureFunctionsRegistered(); + + auto nullable_int_type = makeNullable(std::make_shared()); + auto actions = std::make_shared(NamesAndTypes{ + {outer_probe_key_name, nullable_int_type}, + {outer_probe_value_name, nullable_int_type}, + {outer_build_key_name, nullable_int_type}, + {outer_build_value_name, nullable_int_type}, + }); + auto equals_builder = FunctionFactory::instance().get("equals", *TiFlashTestEnv::getContext()); + actions->add(ExpressionAction::applyFunction( + equals_builder, + {outer_probe_value_name, outer_build_value_name}, + full_other_cond_name)); + + JoinNonEqualConditions conditions; + conditions.other_cond_name = full_other_cond_name; + conditions.other_cond_expr = actions; + return conditions; +} + +JoinNonEqualConditions makeOuterJoinSideConditions( + const String & left_filter_column = "", + const String & right_filter_column = "") +{ + JoinNonEqualConditions conditions; + conditions.left_filter_column = left_filter_column; + conditions.right_filter_column = right_filter_column; + return conditions; +} + +JoinPtr makeOuterJoinTestJoin( + ASTTableJoin::Kind kind, + const DataTypePtr & key_type, + const JoinNonEqualConditions & non_equal_conditions = JoinNonEqualConditions{}, + const String & flag_helper_name = "") +{ + auto nullable_value_type = makeNullable(std::make_shared()); + SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); + SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); + return std::make_shared( + Names{outer_probe_key_name}, + Names{outer_build_key_name}, + std::vector{1}, + kind, + "join_null_eq_outer_test", + 0, + 0, + build_spill_config, + probe_spill_config, + RestoreConfig{1, 0, 0}, + NamesAndTypes{ + {outer_probe_key_name, key_type}, + {outer_probe_value_name, nullable_value_type}, + {outer_build_key_name, key_type}, + {outer_build_value_name, nullable_value_type}, + }, + RegisterOperatorSpillContext{}, + nullptr, + TiDB::TiDBCollators{}, + non_equal_conditions, + 1024, + 0, + "", + flag_helper_name, + 0, + true); +} + +JoinPtr makeOuterJoinTestJoin( + ASTTableJoin::Kind kind, + const JoinNonEqualConditions & non_equal_conditions = JoinNonEqualConditions{}, + const String & flag_helper_name = "") +{ + return makeOuterJoinTestJoin( + kind, + makeNullable(std::make_shared()), + non_equal_conditions, + flag_helper_name); +} + +JoinPtr makeSemiJoinTestJoin(ASTTableJoin::Kind kind) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); + SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); + return std::make_shared( + Names{outer_probe_key_name}, + Names{outer_build_key_name}, + std::vector{1}, + kind, + "join_null_eq_semi_test", + 0, + 0, + build_spill_config, + probe_spill_config, + RestoreConfig{1, 0, 0}, + NamesAndTypes{ + {outer_probe_key_name, nullable_int_type}, + {outer_probe_value_name, int_type}, + }, + RegisterOperatorSpillContext{}, + nullptr, + TiDB::TiDBCollators{}, + JoinNonEqualConditions{}, + 1024, + 0, + "", + "", + 0, + true); +} + +JoinPtr makeMixedKeyJoin(const std::vector & is_null_eq, const DataTypePtr & key_type) +{ + auto int_type = std::make_shared(); + SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); + SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); + return std::make_shared( + Names{mixed_probe_key1_name, mixed_probe_key2_name}, + Names{mixed_build_key1_name, mixed_build_key2_name}, + is_null_eq, + ASTTableJoin::Kind::Inner, + "join_null_eq_mixed_test", + 0, + 0, + build_spill_config, + probe_spill_config, + RestoreConfig{1, 0, 0}, + NamesAndTypes{ + {mixed_probe_key1_name, key_type}, + {mixed_probe_key2_name, key_type}, + {mixed_probe_value_name, int_type}, + {mixed_build_key1_name, key_type}, + {mixed_build_key2_name, key_type}, + {mixed_build_value_name, int_type}, + }, + RegisterOperatorSpillContext{}, + nullptr, + TiDB::TiDBCollators{}, + JoinNonEqualConditions{}, + 1024, + 0, + "", + "", + 0, + true); +} + +JoinPtr makeMixedKeyJoin(const std::vector & is_null_eq) +{ + return makeMixedKeyJoin(is_null_eq, makeNullable(std::make_shared())); +} + +Block makeOuterProbeSampleBlock(bool include_filter = false) +{ + return makeOuterProbeSampleBlock(makeNullable(std::make_shared()), include_filter); +} + +Block makeOuterProbeSampleBlock(const DataTypePtr & key_type, bool include_filter = false) +{ + auto int_type = std::make_shared(); + auto block = Block{ + {key_type->createColumn(), key_type, outer_probe_key_name}, + {int_type->createColumn(), int_type, outer_probe_value_name}, + }; + if (include_filter) + { + auto uint8_type = std::make_shared(); + block.insert({uint8_type->createColumn(), uint8_type, outer_probe_filter_name}); + } + return block; +} + +Block makeOuterBuildSampleBlock(bool include_filter = false) +{ + return makeOuterBuildSampleBlock(makeNullable(std::make_shared()), include_filter); +} + +Block makeOuterBuildSampleBlock(const DataTypePtr & key_type, bool include_filter = false) +{ + auto int_type = std::make_shared(); + auto block = Block{ + {key_type->createColumn(), key_type, outer_build_key_name}, + {int_type->createColumn(), int_type, outer_build_value_name}, + }; + if (include_filter) + { + auto uint8_type = std::make_shared(); + block.insert({uint8_type->createColumn(), uint8_type, outer_build_filter_name}); + } + return block; +} + +Block makeMixedProbeSampleBlock(const DataTypePtr & key_type) +{ + auto int_type = std::make_shared(); + return Block{ + {key_type->createColumn(), key_type, mixed_probe_key1_name}, + {key_type->createColumn(), key_type, mixed_probe_key2_name}, + {int_type->createColumn(), int_type, mixed_probe_value_name}, + }; +} + +Block makeMixedBuildSampleBlock(const DataTypePtr & key_type) +{ + auto int_type = std::make_shared(); + return Block{ + {key_type->createColumn(), key_type, mixed_build_key1_name}, + {key_type->createColumn(), key_type, mixed_build_key2_name}, + {int_type->createColumn(), int_type, mixed_build_value_name}, + }; +} + +template +ColumnPtr makeNullableNumberColumn(std::initializer_list> values) +{ + auto nested = ColumnType::create(); + auto null_map = ColumnUInt8::create(); + nested->reserve(values.size()); + null_map->reserve(values.size()); + auto & nested_data = nested->getData(); + auto & null_map_data = null_map->getData(); + for (const auto & value : values) + { + if (value.has_value()) + { + nested_data.push_back(*value); + null_map_data.push_back(0); + } + else + { + nested_data.push_back(0); + null_map_data.push_back(1); + } + } + return ColumnNullable::create(std::move(nested), std::move(null_map)); +} + +ColumnPtr makeNullableInt32Column(std::initializer_list> values) +{ + return makeNullableNumberColumn(values); +} + +ColumnPtr makeNullableInt64Column(std::initializer_list> values) +{ + return makeNullableNumberColumn(values); +} + +ColumnPtr makeNullableStringColumn(std::initializer_list> values) +{ + auto nested = ColumnString::create(); + auto null_map = ColumnUInt8::create(); + null_map->reserve(values.size()); + auto & null_map_data = null_map->getData(); + for (const auto & value : values) + { + if (value.has_value()) + { + nested->insertData(value->data(), value->size()); + null_map_data.push_back(0); + } + else + { + nested->insertData("", 0); + null_map_data.push_back(1); + } + } + return ColumnNullable::create(std::move(nested), std::move(null_map)); +} + +ColumnPtr makeNullableFixedStringColumn(size_t string_size, std::initializer_list> values) +{ + auto nested = ColumnFixedString::create(string_size); + auto null_map = ColumnUInt8::create(); + null_map->reserve(values.size()); + auto & null_map_data = null_map->getData(); + for (const auto & value : values) + { + if (value.has_value()) + { + nested->insertData(value->data(), value->size()); + null_map_data.push_back(0); + } + else + { + nested->insertData("", 0); + null_map_data.push_back(1); + } + } + return ColumnNullable::create(std::move(nested), std::move(null_map)); +} + +ColumnPtr makeUInt8Column(std::initializer_list values) +{ + auto column = ColumnUInt8::create(); + column->reserve(values.size()); + auto & data = column->getData(); + for (auto value : values) + data.push_back(value); + return column; +} + +ColumnPtr makeInt32Column(std::initializer_list values) +{ + auto column = ColumnInt32::create(); + column->reserve(values.size()); + auto & data = column->getData(); + for (auto value : values) + data.push_back(value); + return column; +} + +Block readAllBlocks(const BlockInputStreamPtr & stream) +{ + stream->readPrefix(); + Blocks blocks; + while (true) + { + auto block = stream->read(); + if (!block) + break; + blocks.push_back(std::move(block)); + } + stream->readSuffix(); + if (blocks.empty()) + return stream->getHeader().cloneEmpty(); + return vstackBlocks(std::move(blocks)); +} + +std::optional getInt32Value(const Block & block, const String & name, size_t row) +{ + const auto & column = block.getByName(name).column; + if (const auto * nullable_column = checkAndGetColumn(column.get()); nullable_column != nullptr) + { + if (nullable_column->getNullMapData()[row] != 0) + return std::nullopt; + return checkAndGetColumn(nullable_column->getNestedColumnPtr().get())->getData()[row]; + } + return checkAndGetColumn(column.get())->getData()[row]; +} + +void prepareAndFinalizeOuterJoin( + const JoinPtr & join, + bool include_probe_filter = false, + bool include_build_filter = false) +{ + join->initBuild(makeOuterBuildSampleBlock(include_build_filter), 1); + join->initProbe(makeOuterProbeSampleBlock(include_probe_filter), 1); + join->finalize(Names{ + outer_probe_key_name, + outer_probe_value_name, + outer_build_key_name, + outer_build_value_name, + }); +} + +void prepareAndFinalizeOuterJoin( + const JoinPtr & join, + const DataTypePtr & key_type, + bool include_probe_filter = false, + bool include_build_filter = false) +{ + join->initBuild(makeOuterBuildSampleBlock(key_type, include_build_filter), 1); + join->initProbe(makeOuterProbeSampleBlock(key_type, include_probe_filter), 1); + join->finalize(Names{ + outer_probe_key_name, + outer_probe_value_name, + outer_build_key_name, + outer_build_value_name, + }); +} + +void prepareAndFinalizeSemiJoin(const JoinPtr & join) +{ + join->initBuild(makeOuterBuildSampleBlock(), 1); + join->initProbe(makeOuterProbeSampleBlock(), 1); + join->finalize(Names{ + outer_probe_key_name, + outer_probe_value_name, + }); +} + +void prepareAndFinalizeMixedJoin(const JoinPtr & join) +{ + prepareAndFinalizeMixedJoin(join, makeNullable(std::make_shared())); +} + +void prepareAndFinalizeMixedJoin(const JoinPtr & join, const DataTypePtr & key_type) +{ + join->initBuild(makeMixedBuildSampleBlock(key_type), 1); + join->initProbe(makeMixedProbeSampleBlock(key_type), 1); + join->finalize(Names{ + mixed_probe_key1_name, + mixed_probe_key2_name, + mixed_probe_value_name, + mixed_build_key1_name, + mixed_build_key2_name, + mixed_build_value_name, + }); +} +} // namespace + +TEST(JoinNullEqTest, NullableNullEqKeyUsesNullablePackedJoinMapMethod) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto join = makeTestJoin(nullable_int_type, {1}); + join->initBuild(makeSampleBlock(nullable_int_type), 1); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::nullable_keys128); +} + +TEST(JoinNullEqTest, NullableMixedNullEqKeysCanUseNullableKeys256JoinMapMethod) +{ + auto nullable_int64_type = makeNullable(std::make_shared()); + auto join = makeMixedKeyJoin({1, 1}, nullable_int64_type); + join->initBuild(makeMixedBuildSampleBlock(nullable_int64_type), 1); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::nullable_keys256); +} + +TEST(JoinNullEqTest, NullableMixedNullEqKeys256JoinProducesJoinedRow) +{ + auto nullable_int64_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeMixedKeyJoin({1, 1}, nullable_int64_type); + prepareAndFinalizeMixedJoin(join, nullable_int64_type); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::nullable_keys256); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt64Column({std::nullopt}), nullable_int64_type, mixed_build_key1_name}, + {makeNullableInt64Column({11}), nullable_int64_type, mixed_build_key2_name}, + {makeInt32Column({100}), int_type, mixed_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt64Column({std::nullopt}), nullable_int64_type, mixed_probe_key1_name}, + {makeNullableInt64Column({11}), nullable_int64_type, mixed_probe_key2_name}, + {makeInt32Column({10}), int_type, mixed_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, NullableStringNullEqFallsBackToSerializedJoinMapMethod) +{ + auto nullable_string_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::Inner, nullable_string_type); + prepareAndFinalizeOuterJoin(join, nullable_string_type); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::serialized); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableStringColumn({std::nullopt, "alpha"}), nullable_string_type, outer_build_key_name}, + {makeInt32Column({100, 200}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableStringColumn({std::nullopt, "alpha", "beta"}), nullable_string_type, outer_probe_key_name}, + {makeInt32Column({10, 20, 30}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 2); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 1), 20); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 1), 200); +} + +TEST(JoinNullEqTest, OversizedNullableFixedKeysFallBackToSerializedJoinMapMethod) +{ + constexpr size_t fixed_string_size = 16; + auto nullable_fixed_string_type = makeNullable(std::make_shared(fixed_string_size)); + auto int_type = std::make_shared(); + auto join = makeMixedKeyJoin({1, 1}, nullable_fixed_string_type); + prepareAndFinalizeMixedJoin(join, nullable_fixed_string_type); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::serialized); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableFixedStringColumn(fixed_string_size, {std::nullopt}), + nullable_fixed_string_type, + mixed_build_key1_name}, + {makeNullableFixedStringColumn(fixed_string_size, {"abcdefghijklmnop"}), + nullable_fixed_string_type, + mixed_build_key2_name}, + {makeInt32Column({100}), int_type, mixed_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableFixedStringColumn(fixed_string_size, {std::nullopt, std::nullopt}), + nullable_fixed_string_type, + mixed_probe_key1_name}, + {makeNullableFixedStringColumn(fixed_string_size, {"abcdefghijklmnop", "qrstuvwxyzabcdef"}), + nullable_fixed_string_type, + mixed_probe_key2_name}, + {makeInt32Column({10, 20}), int_type, mixed_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, DefaultMethodSelectionRemainsForOtherCases) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto nullable_non_null_eq_join = makeTestJoin(nullable_int_type, {0}); + nullable_non_null_eq_join->initBuild(makeSampleBlock(nullable_int_type), 1); + ASSERT_EQ(nullable_non_null_eq_join->getJoinMapMethod(), JoinMapMethod::key32); + + auto int_type = std::make_shared(); + auto non_nullable_null_eq_join = makeTestJoin(int_type, {1}); + non_nullable_null_eq_join->initBuild(makeSampleBlock(int_type), 1); + ASSERT_EQ(non_nullable_null_eq_join->getJoinMapMethod(), JoinMapMethod::key32); +} + +TEST(JoinNullEqTest, NullableNullEqBuildRowsAreInsertedIntoHashMap) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto join = makeTestJoin(nullable_int_type, {1}); + join->initBuild(makeSampleBlock(nullable_int_type), 1); + + Block build_block{ + {makeNullableInt32Column({std::nullopt, 7}), nullable_int_type, test_key_name}, + }; + join->insertFromBlock(build_block, 0); + + ASSERT_EQ(join->getTotalRowCount(), 2); +} + +TEST(JoinNullEqTest, ProbeRowFilterSkipsOnlyNonNullEqNullKeys) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto uint8_type = std::make_shared(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({1, std::nullopt, 2}), nullable_int_type, probe_eq_key_name}, + {makeNullableInt32Column({std::nullopt, 10, 20}), nullable_int_type, probe_null_eq_key_name}, + {makeUInt8Column({1, 1, 0}), uint8_type, probe_filter_name}, + }); + + probe_process_info.prepareForHashProbe( + Names{probe_eq_key_name, probe_null_eq_key_name}, + std::vector{0, 1}, + probe_filter_name, + ASTTableJoin::Kind::Inner, + ASTTableJoin::Strictness::All, + false, + TiDB::TiDBCollators{}, + 0); + + ASSERT_NE(probe_process_info.row_filter_map, nullptr); + ASSERT_FALSE(probe_process_info.hash_join_data->key_columns[0]->isColumnNullable()); + ASSERT_TRUE(probe_process_info.hash_join_data->key_columns[1]->isColumnNullable()); + EXPECT_EQ((*probe_process_info.row_filter_map)[0], 0); + EXPECT_EQ((*probe_process_info.row_filter_map)[1], 1); + EXPECT_EQ((*probe_process_info.row_filter_map)[2], 1); +} + +TEST(JoinNullEqTest, InnerJoinNullEqNullMatchProducesJoinedRow) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::Inner); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, InnerJoinNullEqNullProbeRowDoesNotMatchNonNullBuildRow) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::Inner); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({7}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + EXPECT_EQ(probe_result.rows(), 0); +} + +TEST(JoinNullEqTest, LeftOuterNullEqNullMatchDoesNotBecomeUnmatched) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::LeftOuter); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, LeftOuterNullEqNullProbeRowStaysUnmatchedAgainstNonNullBuildRow) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::LeftOuter); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({7}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), std::nullopt); +} + +TEST(JoinNullEqTest, LeftOuterNullEqLeftConditionOnlyFiltersConditionFailure) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto uint8_type = std::make_shared(); + auto join = makeOuterJoinTestJoin( + ASTTableJoin::Kind::LeftOuter, + makeOuterJoinSideConditions(outer_probe_filter_name, "")); + prepareAndFinalizeOuterJoin(join, true, false); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt, std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10, 20}), int_type, outer_probe_value_name}, + {makeUInt8Column({1, 0}), uint8_type, outer_probe_filter_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 2); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); + + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 1), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 1), 20); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 1), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 1), std::nullopt); +} + +TEST(JoinNullEqTest, SemiJoinNullEqKeepsOnlyProbeRowsThatMatch) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeSemiJoinTestJoin(ASTTableJoin::Kind::Semi); + prepareAndFinalizeSemiJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt, 7}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100, 200}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt, 7, 8}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10, 20, 30}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 2); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 1), 7); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 1), 20); +} + +TEST(JoinNullEqTest, AntiJoinNullEqKeepsOnlyProbeRowsThatDoNotMatch) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeSemiJoinTestJoin(ASTTableJoin::Kind::Anti); + prepareAndFinalizeSemiJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt, 7}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100, 200}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt, 7, 8}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10, 20, 30}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), 8); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 30); +} + +TEST(JoinNullEqTest, MixedJoinKeysNullEqThenEqOnlyFirstKeyIsNullSafe) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeMixedKeyJoin({1, 0}); + prepareAndFinalizeMixedJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt, std::nullopt}), nullable_int_type, mixed_build_key1_name}, + {makeNullableInt32Column({1, std::nullopt}), nullable_int_type, mixed_build_key2_name}, + {makeInt32Column({100, 200}), int_type, mixed_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt, std::nullopt}), nullable_int_type, mixed_probe_key1_name}, + {makeNullableInt32Column({1, std::nullopt}), nullable_int_type, mixed_probe_key2_name}, + {makeInt32Column({10, 20}), int_type, mixed_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_key1_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_key2_name, 0), 1); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_key1_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_key2_name, 0), 1); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, MixedJoinKeysAllNullEqAllowAllNullPairToMatch) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeMixedKeyJoin({1, 1}); + prepareAndFinalizeMixedJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, mixed_build_key1_name}, + {makeNullableInt32Column({std::nullopt}), nullable_int_type, mixed_build_key2_name}, + {makeInt32Column({100}), int_type, mixed_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, mixed_probe_key1_name}, + {makeNullableInt32Column({std::nullopt}), nullable_int_type, mixed_probe_key2_name}, + {makeInt32Column({10}), int_type, mixed_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_key1_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_key2_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_key1_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_key2_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, MixedJoinKeysEqThenNullEqOnlySecondKeyIsNullSafe) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeMixedKeyJoin({0, 1}); + prepareAndFinalizeMixedJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({7, std::nullopt}), nullable_int_type, mixed_build_key1_name}, + {makeNullableInt32Column({std::nullopt, std::nullopt}), nullable_int_type, mixed_build_key2_name}, + {makeInt32Column({100, 200}), int_type, mixed_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({7, std::nullopt}), nullable_int_type, mixed_probe_key1_name}, + {makeNullableInt32Column({std::nullopt, std::nullopt}), nullable_int_type, mixed_probe_key2_name}, + {makeInt32Column({10, 20}), int_type, mixed_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_key1_name, 0), 7); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_key2_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_key1_name, 0), 7); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_key2_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, mixed_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, RightOuterNullEqNullMatchDoesNotLeakToScanAfterProbe) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::RightOuter); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); + + ASSERT_TRUE(join->finishOneProbe(0)); + join->finalizeProbe(); + + Block scan_result = readAllBlocks(join->createScanHashMapAfterProbeStream(makeOuterProbeSampleBlock(), 0, 1, 1024)); + EXPECT_EQ(scan_result.rows(), 0); +} + +TEST(JoinNullEqTest, RightOuterNullEqUnmatchedNullBuildRowStillScansFromHashMap) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::RightOuter); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ASSERT_TRUE(join->finishOneProbe(0)); + join->finalizeProbe(); + + Block scan_result = readAllBlocks(join->createScanHashMapAfterProbeStream(makeOuterProbeSampleBlock(), 0, 1, 1024)); + ASSERT_EQ(scan_result.rows(), 1); + EXPECT_EQ(getInt32Value(scan_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_probe_value_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, RightOuterNullEqRightConditionFilteredBuildRowStillScansAfterProbe) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto uint8_type = std::make_shared(); + auto join = makeOuterJoinTestJoin( + ASTTableJoin::Kind::RightOuter, + makeOuterJoinSideConditions("", outer_build_filter_name)); + prepareAndFinalizeOuterJoin(join, false, true); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt, std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100, 200}), int_type, outer_build_value_name}, + {makeUInt8Column({1, 0}), uint8_type, outer_build_filter_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); + + ASSERT_TRUE(join->finishOneProbe(0)); + join->finalizeProbe(); + + Block scan_result = readAllBlocks(join->createScanHashMapAfterProbeStream(makeOuterProbeSampleBlock(), 0, 1, 1024)); + ASSERT_EQ(scan_result.rows(), 1); + EXPECT_EQ(getInt32Value(scan_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_probe_value_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_build_value_name, 0), 200); +} + +TEST(JoinNullEqTest, FullJoinNullEqNullMatchDoesNotSplitIntoTwoUnmatchedRows) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::Full); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); + + ASSERT_TRUE(join->finishOneProbe(0)); + join->finalizeProbe(); + + Block scan_result = readAllBlocks(join->createScanHashMapAfterProbeStream(makeOuterProbeSampleBlock(), 0, 1, 1024)); + EXPECT_EQ(scan_result.rows(), 0); +} + +TEST(JoinNullEqTest, FullJoinNullEqMatchWithOtherConditionFalseKeepsBuildRowForScanAfterProbe) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::Full, makeFullJoinOtherCondition(), full_flag_helper_name); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({10}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 10); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), std::nullopt); + + ASSERT_TRUE(join->finishOneProbe(0)); + join->finalizeProbe(); + + Block scan_result = readAllBlocks(join->createScanHashMapAfterProbeStream(makeOuterProbeSampleBlock(), 0, 1, 1024)); + ASSERT_EQ(scan_result.rows(), 1); + EXPECT_EQ(getInt32Value(scan_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_probe_value_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(scan_result, outer_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, FullJoinNullEqMatchWithOtherConditionTrueConsumesBuildRow) +{ + auto nullable_int_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto join = makeOuterJoinTestJoin(ASTTableJoin::Kind::Full, makeFullJoinOtherCondition(), full_flag_helper_name); + prepareAndFinalizeOuterJoin(join); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt32Column({std::nullopt}), nullable_int_type, outer_probe_key_name}, + {makeInt32Column({100}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 100); + EXPECT_EQ(getInt32Value(probe_result, outer_build_key_name, 0), std::nullopt); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); + + ASSERT_TRUE(join->finishOneProbe(0)); + join->finalizeProbe(); + + Block scan_result = readAllBlocks(join->createScanHashMapAfterProbeStream(makeOuterProbeSampleBlock(), 0, 1, 1024)); + EXPECT_EQ(scan_result.rows(), 0); +} + +} // namespace DB::tests diff --git a/dbms/src/TestUtils/ColumnsToTiPBExpr.h b/dbms/src/TestUtils/ColumnsToTiPBExpr.h index ecc5ddb01e1..409a1f62f37 100644 --- a/dbms/src/TestUtils/ColumnsToTiPBExpr.h +++ b/dbms/src/TestUtils/ColumnsToTiPBExpr.h @@ -20,6 +20,7 @@ #include #include #include +#include namespace DB { diff --git a/dbms/src/TestUtils/mockExecutor.cpp b/dbms/src/TestUtils/mockExecutor.cpp index fee6c19a0a8..af450e35618 100644 --- a/dbms/src/TestUtils/mockExecutor.cpp +++ b/dbms/src/TestUtils/mockExecutor.cpp @@ -331,7 +331,8 @@ DAGRequestBuilder & DAGRequestBuilder::join( MockAstVec other_eq_conds_from_in, uint64_t fine_grained_shuffle_stream_count, bool is_null_aware_semi_join, - int64_t inner_index) + int64_t inner_index, + std::vector is_null_eq) { assert(root); assert(right.root); @@ -341,6 +342,7 @@ DAGRequestBuilder & DAGRequestBuilder::join( right.root, tp, join_col_exprs, + is_null_eq, left_conds, right_conds, other_conds, diff --git a/dbms/src/TestUtils/mockExecutor.h b/dbms/src/TestUtils/mockExecutor.h index a1d43880f4e..b0fae2cfe6b 100644 --- a/dbms/src/TestUtils/mockExecutor.h +++ b/dbms/src/TestUtils/mockExecutor.h @@ -146,7 +146,8 @@ class DAGRequestBuilder MockAstVec other_eq_conds_from_in, uint64_t fine_grained_shuffle_stream_count = 0, bool is_null_aware_semi_join = false, - int64_t inner_index = 1); + int64_t inner_index = 1, + std::vector is_null_eq = {}); DAGRequestBuilder & join( const DAGRequestBuilder & right, tipb::JoinType tp, diff --git a/dbms/src/TestUtils/tests/gtest_mock_executors.cpp b/dbms/src/TestUtils/tests/gtest_mock_executors.cpp index deb2dd8b0a8..2a41fb615fa 100644 --- a/dbms/src/TestUtils/tests/gtest_mock_executors.cpp +++ b/dbms/src/TestUtils/tests/gtest_mock_executors.cpp @@ -278,6 +278,31 @@ try } CATCH +TEST_F(MockDAGRequestTest, JoinNullEqSerialization) +try +{ + auto request = context.scan("test_db", "l_table") + .join( + context.scan("test_db", "r_table"), + tipb::JoinType::TypeInnerJoin, + {col("join_c")}, + {}, + {}, + {}, + {}, + 0, + false, + 1, + {1}) + .build(context); + + ASSERT_EQ(request->root_executor().tp(), tipb::ExecType::TypeJoin); + const auto & join = request->root_executor().join(); + ASSERT_EQ(join.is_null_eq_size(), 1); + ASSERT_TRUE(join.is_null_eq(0)); +} +CATCH + TEST_F(MockDAGRequestTest, FullOuterJoinSchemaIsNullable) try { diff --git a/docs/note/nulleq_join.md b/docs/note/nulleq_join.md new file mode 100644 index 00000000000..56211d59e0f --- /dev/null +++ b/docs/note/nulleq_join.md @@ -0,0 +1,601 @@ +# TiFlash NullEQ Join Key(`<=>` / `tidbNullEQ`)设计文档 + +## 背景 + +TiFlash 当前 Hash Join 的默认等值语义是: + +- `NULL` 不参与等值匹配 +- build 侧含 `NULL` key 的行不会进入 hash map +- probe 侧含 `NULL` key 的行会被直接当作 not matched + +这与 Null-safe equal(`<=>` / `tidbNullEQ`)要求的语义不同: + +- `NULL <=> NULL` 为 `true` +- `NULL <=> non-NULL` 为 `false` +- `non-NULL <=> non-NULL` 与普通 `=` 一致 + +本设计文档讨论的是:在 TiFlash 已经支持 `FULL OUTER JOIN` 的前提下,如何为 hash join 增加 **join key 粒度的 NullEQ 语义**。 + +## 目标 + +1. TiFlash 的 Hash Join 在 **join key** 使用 NullEQ 语义时结果正确。 +2. 支持 **key 粒度混合语义**: + - 同一个 join 中允许部分 key 走 `=` + - 允许部分 key 走 `<=>` +3. 未下发 NullEQ 标记时,保持现有行为不变。 +4. 与已经支持的 `FULL OUTER JOIN` 语义兼容,不引入 `NULL <=> NULL` 相关的 outer join 错误结果。 + +## 非目标 + +1. 不把 `other_conditions` 中的 `<=>` 纳入本文新增的 join-key NullEQ 语义范围;若 planner 下发了这类表达式,按普通 other condition 处理。 +2. MVP 不追求最优性能,允许为了正确性强制走 `serialized`。 +3. 不扩大 NullAware join(`NOT IN` 家族)的语义覆盖范围;MVP 阶段建议 fail-fast。 +4. 不在本轮实现 cartesian full join 与 NullEQ 的组合。 + +## 作用范围 + +本轮 scope 限定为: + +- hash join +- `left_join_keys/right_join_keys` 非空 +- NullEQ 只出现在 join key 上 + +不包含: + +- cartesian join +- 仅通过 `other_conditions` 表达 NullEQ join 语义的计划形态 +- planner 把 `<=>` 重写成其它表达式后再由 TiFlash 反推语义 + +## 输入契约 + +### tipb 协议 + +建议在 `tipb::Join` 中增加: + +- `repeated bool is_null_eq = ...;` + +语义如下: + +- `is_null_eq[i] = false`:第 `i` 对 join key 使用普通 `=` +- `is_null_eq[i] = true`:第 `i` 对 join key 使用 `<=>` + +长度约束: + +- `is_null_eq_size == 0`:视为全 `false`,兼容旧版本 +- 否则必须满足: + - `is_null_eq_size == left_join_keys_size` + - `is_null_eq_size == right_join_keys_size` + +### Join key 表达形式 + +MVP 假设 join key 由 planner 下发为列引用: + +- `left_join_keys[i]` 与 `right_join_keys[i]` 是一一对齐的 key pair +- TiFlash 如需在执行层插入 cast 做类型对齐,不改变 key 的顺序和数量 +- `is_null_eq[i]` 始终按 key pair index 对齐,而不是按 build/probe 角色对齐 + +### 语义边界 + +NullEQ 语义只通过 `is_null_eq[]` 表达: + +- 若 `<=>` 出现在 `other_conditions` 中,则按普通布尔表达式处理;本文不要求执行层从 `other_conditions` 里的 `<=>` 反推出“这是 join key NullEQ” +- 不让执行层从通用表达式里反推某个 key 是否是 NullEQ + +### 与 NullAware join 的关系 + +`is_null_aware_semi_join` 与 NullEQ 是两套不同语义: + +- NullAware join 关注 `NOT IN` 的三值逻辑 +- NullEQ 关注 join key 的比较语义 + +MVP 建议: + +- 若 `is_null_aware_semi_join=true` 且存在任意 `is_null_eq[i]=true`,直接 fail-fast + +原因是这两条路径都对“NULL key 行怎么处理”有强假设,混用很容易产生 silent wrong result。 + +## 当前实现的关键假设 + +当前 Join 框架里,与 NullEQ 直接冲突的假设主要有四类。 + +### 1. key-NULL 会被提前过滤 + +当前 build/probe 都会把 nullable key 做两件事: + +1. 把 `ColumnNullable` 替换成 nested column +2. 把 key 中的 `NULL` 行写入 `null_map` + +对应路径: + +- build:`Join::insertFromBlockInternal()` +- probe:`ProbeProcessInfo::prepareForHashProbe()` + +这意味着: + +- build 侧 `NULL` key 行默认不入 map +- probe 侧 `NULL` key 行默认不 probe map + +这与 NullEQ 的 `NULL <=> NULL` 可以匹配直接冲突。 + +### 2. side-condition 与 key-NULL 共用一张 null_map + +当前 `recordFilteredRows()` 会复用同一张 `null_map`,把 side-condition 过滤结果与 “key 是否为 NULL” 混到一起。 + +对 NullEQ 来说,问题不在于执行链路必须长期维护两张独立的 map,而在于: + +- 普通 `=` key 的 `NULL` 过滤 +- left/right side-condition 过滤 + +这两类来源在**生成过滤结果**时必须区分,因为: + +- 对普通 `=` key,应把 key 的 `NULL` 写入最终过滤结果 +- 对 NullEQ key,不应把 key 的 `NULL` 写入最终过滤结果 +- side-condition 的过滤结果则始终需要写入最终过滤结果 + +因此更准确的做法是: + +- 先按 key 粒度决定哪些 `NULL` 需要参与过滤 +- 再与 side-condition 的过滤结果合并成一张统一的 `row_filter_map` + +也就是说,最终可以只有一张“这一行是否跳过 insert/probe”的 map,但不能继续沿用当前这种“先无差别把所有 key-NULL 都写进 null_map,再复用它叠加 side-condition”的实现方式。 + +### 3. RowsNotInsertToMap / scan-after-probe 默认把 NULL key 当作天然 unmatched + +对于 right/full/right semi/right anti/null-aware 这些需要保留 build 侧特殊行的 join kind,当前实现会把“未入 map 的 build 行”记进 `RowsNotInsertToMap`,之后在 scan-after-probe 阶段输出。 + +在普通 `=` 语义下这成立,因为 key-NULL 本来就不参与匹配。 + +但在 NullEQ 语义下: + +- `NULL` key 行不一定是 unmatched +- 它可能应该入 map,并与 probe 侧 `NULL` key 行成功匹配 + +### 4. KeyGetter 默认不编码 nullable bitmap + +当前 `keys128/keys256` 这类 fixed key hash method 默认是 `has_nullable_keys = false`。 + +这意味着即使不提前过滤 `NULL`,现有 packed key 路径也未必能正确把 nullness 编进 hash key。 + +## 与 FULL OUTER JOIN 的额外交互 + +NullEQ 本身不是 `FULL OUTER JOIN` 专属问题,`LEFT OUTER JOIN` 和 `RIGHT OUTER JOIN` 也会受影响。 +但在 TiFlash 已经支持 `FULL OUTER JOIN` 之后,有几件事必须在设计里显式纳入,否则很容易出现双边都错的结果。 + +### 1. “NULL key 走天然 unmatched 路径”不是 full 特有问题,但 full 会把问题放大 + +这件事对不同 outer join 的影响不同: + +- `LEFT OUTER JOIN` + - probe 侧 `NULL` key 若仍直接走 `addNotFound()`,本该命中的 `NULL <=> NULL` 会被错误输出成左 unmatched +- `RIGHT OUTER JOIN` + - build 侧 `NULL` key 若仍进 `RowsNotInsertToMap`,本该命中的行会在 scan-after-probe 阶段被错误输出成右 unmatched +- `FULL OUTER JOIN` + - 上述两条路径会同时存在 + - 一组本该匹配的 `NULL <=> NULL` 行,可能被错误拆成: + - 一条左 unmatched + - 一条右 unmatched + +所以这不是 full 独有问题,但 full 会把问题表现得最明显、也最复杂。 + +### 2. FULL + other condition 必须继续沿用“延后 setUsed”语义 + +当前 full 分支已经为 `full + other condition` 做了专门修正: + +- key 命中时不能立刻 `setUsed()` +- 必须等 `other condition` 真正通过后,再标记 build 行为 used + +否则 probe 后扫描阶段会漏输出本应作为 unmatched build 行的记录。 + +在 NullEQ 引入后,这个约束仍然成立,而且要覆盖 `NULL <=> NULL` 命中的情况: + +- key 通过是因为 `NULL <=> NULL` +- other condition 失败 +- 正确语义应该是: + - 左侧保留一条右补 null 的 unmatched 行 + - 右侧 build 行仍然在后扫阶段输出为 unmatched + +因此 NullEQ 不能绕开 full 分支当前的 row-flagged / delayed-used 设计。 + +### 3. RowsNotInsertToMap 在 full 下要重新定义语义 + +在支持 full 之后,`RowsNotInsertToMap` 不能再简单理解成“所有 NULL key 行 + 所有 build condition 失败的行”。 + +更准确的语义应该是: + +- build side-condition 失败的行 +- 普通 `=` key 因 key-NULL 被过滤的行 + +不应包含: + +- NullEQ key 为 `NULL` 的行 + +因为这些行应该入 map,并可能成功匹配。 + +### 4. dispatch hash / spill / fine-grained shuffle 在 full 下更容易暴露错误 + +如果 build/probe 的 dispatch hash 没有把 nullness 编进 key: + +- build 侧 `NULL` key 与 probe 侧 `NULL` key 可能落到不同 partition +- inner join 下通常表现为“不命中” +- full join 下则可能进一步演变成: + - probe 侧输出一条 unmatched + - build 侧后扫再输出一条 unmatched + +所以 `full + NullEQ + spill/FGS` 应该是 MVP 测试矩阵里的必测项,而不是后续补充项。 + +### 5. full 的 schema nullable 规则不需要为 NullEQ 再单独扩展 + +这一点反而不用新增复杂度: + +- full 输出 schema 两边本来就都应为 nullable +- other-condition 输入 schema 两边也已经按 full 语义处理成 nullable + +NullEQ 改变的是 **匹配语义**,不是 full 输出 schema 的 nullable 规则。 + +## 设计选择 + +## 1. 总体原则 + +NullEQ 设计遵循两个核心原则: + +1. 把“key 是否为 NULL”与“row 是否因 side-condition 被过滤”分离 +2. 让 NullEQ 的 `NULL` 真正进入 key 比较,而不是继续被当成特殊 unmatched 行 + +由此得到两个概念: + +- `row_filter_map` + - 表示这一行不需要 insert/probe + - 原因可以是 left/right condition 失败,也可以是普通 `=` key 的 `NULL` +- `key_null_map` + - 只对普通 `=` key 有意义 + - NullEQ key 不应把 `NULL` 写进这张 map + +这里保留这两个名字,主要是为了说明“过滤结果的来源”。 + +最终实现里,它们完全可以合并成一张统一的 `row_filter_map`: + +- 普通 `=` key 的 `NULL` 可以进入这张 map +- left/right side-condition 的过滤结果也进入这张 map +- 但 NullEQ key 的 `NULL` 不能进入这张 map + +换句话说,关键不是最终一定要维护两张独立的 map,而是生成最终过滤结果时,必须按 key 粒度决定哪些 `NULL` 应该被当作“跳过 insert/probe”的条件。 + +## 2. build/probe 都按 key 粒度区分 `=` 与 `<=>` + +对于每个 key pair: + +- 若 `is_null_eq[i] = false` + - 延续现有 `=` 语义 + - key 中有 `NULL` 时,这一行不参与匹配 +- 若 `is_null_eq[i] = true` + - 保留 nullable key + - `NULL` 可以参与 hash / probe / match + +也就是说,NullEQ 不是“整条 join 全都变 null-safe”,而是按 key pair 生效。 + +## 3. build 路径设计 + +build 阶段的目标是: + +- NullEQ key 为 `NULL` 的行可以入 map +- 普通 `=` key 为 `NULL` 的行仍不入 map +- side-condition 失败的行不入 map,但 outer join 语义所需的保底输出仍要保留 + +建议做法: + +1. 从原始 key columns 出发,不再无条件对所有 key 调用 `extractNestedColumnsAndNullMap` +2. 遍历每个 key: + - 对 `=` key: + - 若是 nullable,则取 nested column + - 并把该列 null map OR 进 `row_filter_map` + - 对 `<=>` key: + - 保留 `ColumnNullable` + - 不把该列 null map 写进 `row_filter_map` +3. 再把 build side-condition 的过滤结果 OR 进 `row_filter_map` +4. 传给 `JoinPartition::insertBlockIntoMaps(..., row_filter_map, ...)` + +这样 build 路径上的语义就变成: + +- `row_filter_map[i] = 1` + - 这一行不入 map +- `row_filter_map[i] = 0` + - 这一行入 map + +对于 full/right outer/right semi/right anti 这些会记录 build 特殊行的 join kind: + +- 只有 side-condition 失败的行、或者普通 `=` key 的 `NULL` 行,才进入 `RowsNotInsertToMap` +- NullEQ key 的 `NULL` 行不应进入 `RowsNotInsertToMap` + +## 4. probe 路径设计 + +probe 阶段的目标是: + +- NullEQ key 为 `NULL` 的行可以真正 probe map +- 普通 `=` key 的 `NULL` 行仍然按“不匹配”处理 +- left/full outer 语义下,probe unmatched 的保底输出仍然正确 + +建议做法与 build 对称: + +1. 不再无条件对所有 key 做 `extractNestedColumnsAndNullMap` +2. 逐 key 处理: + - `=` key 的 `NULL` 写入 `row_filter_map` + - `<=>` key 保留 nullable,不写入 `row_filter_map` +3. 再把 probe side-condition 的过滤结果 OR 进 `row_filter_map` +4. probe 时: + - `row_filter_map[i] = 1` 的行继续走历史 unmatched 路径 + - `row_filter_map[i] = 0` 的行真正进入 hash probe + +这条规则对 outer join 的影响是: + +- `LEFT OUTER JOIN` 不会再把 NullEQ 的 `NULL` probe 行过早打成 unmatched +- `FULL OUTER JOIN` 同理,但还要与后扫 build unmatched 语义一起对齐 + +## 5. Hash key 编码策略 + +### 当前实现 + +当存在 **nullable 的 NullEQ key** 时,当前 Join map method 的选择规则是: + +- 若参与编码的 key columns 都是 fixed-size,且 `null bitmap + payload` 能放进 `UInt128/UInt256` + - 分别走 `JoinMapMethod::nullable_keys128` / `JoinMapMethod::nullable_keys256` +- 其它情况继续回退到 `JoinMapMethod::serialized` + +fixed-size 路径复用了 HashAgg / Set 已有的 nullable packed keys 思路: + +- `keys128/keys256 + has_nullable_keys = true` +- 把 nullness bitmap 与 key payload 一起编码进 packed key + +这样常见的 nullable numeric / datetime NullEQ join 不必再一律退化到 `serialized`。 + +`serialized` 仍然保留为正确性兜底: + +- 变长 key 仍可自然保留 `ColumnNullable` 的 nullness +- fixed-size key 若带 bitmap 后放不进 `UInt256`,仍可继续工作 + +但这里有一个必须显式满足的前提: + +- `serialized` 只是在“当前列对象长什么样”这个层面保留 nullness +- 它不会自动把 `Nullable(T)` 与 `T` 归一成同一种物理编码 + +当前 `ColumnNullable::serializeValueIntoArena()` 会先写入 null flag,再写 nested value。 +因此对于同一个非空值: + +- `Nullable(Int32)` 的序列化结果 +- `Int32` 的序列化结果 + +并不相同。 + +这意味着: + +- 若某个 NullEQ key pair 一侧是 nullable、另一侧是 non-nullable +- 即使 build/probe 两边都走 `serialized` +- 只要两边最终 key schema 仍分别是 `Nullable(T)` 与 `T` +- 相同的非空值也可能 hash / probe 不命中 + +因此 MVP 不能只做“nullable NullEQ 强制 serialized”,还必须保证: + +- 对每个 `is_null_eq[i] = true` 的 key pair +- 只要任一侧最终需要保留 nullable 语义 +- build/probe 两边就必须在 prepare key 阶段对齐到同一个物理 key schema +- 最直接的做法是统一到 `Nullable(common_type)` + +这一步最初属于 `serialized` 正确性兜底的一部分;在 fixed-size packed key 优化落地后,这个 schema 对齐约束仍然需要继续保持。 + +## 6. JoinPartition / KeyGetter 语义 + +NullEQ 真正落地到 JoinPartition 时,关键不是“有没有 nullable 列”,而是: + +- key getter 能不能把 nullness 编进 key + +当前 JoinPartition 已显式引入 nullable-aware 的 fixed-key KeyGetter 分支: + +- `nullable_keys128 -> HashMethodKeysFixed<..., UInt128, ..., true, false>` +- `nullable_keys256 -> HashMethodKeysFixed<..., UInt256, ..., true, false>` + +对应语义是: + +- packed key 路径会把 nullness bitmap 编进 key +- 变长 key 或超出 `UInt256` 的 fixed-size key 仍走 `serialized` + +无论走哪条路径,仍要保证: + +- build/probe 传进来的 key columns 保留了 NullEQ key 的 nullable 信息 +- 对任意 NullEQ key pair,build/probe 两边最终参与编码的 key schema 一致 +- 尤其是 mixed nullable / non-nullable 的场景,不能保留成 `Nullable(T)` 对 `T` + +## 7. FULL + other condition 语义 + +由于 full 分支已经有 row-flagged 逻辑,NullEQ 这里的要求不是新增一套 full 语义,而是确保 NullEQ key 命中也走已有正确链路: + +1. 对 `full + other condition`: + - 继续使用 row-flagged map +2. 对 key 命中但 other condition 失败的场景: + - build 行的 used 标记必须延后到 other condition 通过后 +3. 这个规则必须覆盖: + - 普通值命中 + - `NULL <=> NULL` 命中 + +否则 full 下会出现漏右行或重复 unmatched 行。 + +## 8. RuntimeFilter + +MVP 建议: + +- 只要 join 含 NullEQ,且存在 nullable 的 NullEQ key,就禁用 runtime filter + +原因: + +- 当前 runtime filter / Set 路径仍然默认丢弃 `NULL` key +- 这与 NullEQ 的 “NULL 可以匹配” 冲突 + +更长期的方向可以是: + +- Set 里额外维护 `has_null` +- 单列 NullEQ key 的 runtime filter 应用语义改成: + - `isNull(x) ? has_null : (x IN set)` + +但这不建议放进 MVP。 + +## 9. 备选方案:Planner 重写 `<=>` + +另一条路线是让 TiDB planner 不显式下发 `is_null_eq[]`,而是把每个 `<=>` key 重写成: + +1. `isNull(k)` +2. `ifNull(k, sentinel)` + +这样 TiFlash 仍然走普通 `=` join。 + +这条路线的优点是执行层改动小,但缺点也很明显: + +- 每个 `<=>` key 变成两个 key +- hash key 变宽 +- planner/runtime filter/cast/collation 都会更绕 +- key 级别语义变得不够直观 + +因此本设计默认选择: + +- TiFlash 原生支持 key 粒度 NullEQ + +## 测试建议 + +MVP 至少应覆盖: + +1. `INNER JOIN` + - `NULL <=> NULL` 命中 + - `NULL <=> 1` 不命中 +2. `LEFT OUTER JOIN` + - probe 侧 `NULL` key 不会被过早当作 unmatched +3. `RIGHT OUTER JOIN` + - build 侧 `NULL` key 不会被错误塞进 `RowsNotInsertToMap` +4. `FULL OUTER JOIN` + - `NULL <=> NULL` 命中时,不会被拆成两条 unmatched + - `NULL <=> NULL` 命中但 `other condition` 失败时,左右 unmatched 都正确 +5. `SEMI / ANTI` + - `NULL <=> NULL` 参与存在性判断 +6. 多列混合语义 + - `k1 <=> k1 AND k2 = k2` + - `k1 <=> k1 AND k2 <=> k2` +7. side-condition 交互 + - left/right condition 与 NullEQ key 共存 +8. spill / fine-grained shuffle + - 特别是 `FULL OUTER JOIN + NullEQ` + +### CP3 测试补充进度 + +按当前 workspace 的进度,CP3 的 spill / FGS 链路已补齐,当前已覆盖: + +1. `spill + FULL OUTER JOIN + NullEQ` + - `NULL <=> NULL` 命中后不会被拆成两条 unmatched +2. `spill + FULL OUTER JOIN + NullEQ + other condition` + - 数据同时覆盖 `other condition = false/true` + - `other condition = false` 时,build 行仍会在 scan-after-probe 正确输出 + - `other condition = true` 时,build 行会被正常消费,不会再次输出 +3. `fine-grained shuffle + NullEQ` + - 覆盖了一组 nullable key + - 验证 build / probe 两侧 key schema 对齐后,probe 不会把 NullEQ 的 `NULL` 误判成 filtered / unmatched + +## 代码热点 + +- `dbms/src/Flash/Coprocessor/JoinInterpreterHelper.*` +- `dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp` +- `dbms/src/Interpreters/Join.h` +- `dbms/src/Interpreters/Join.cpp` +- `dbms/src/Interpreters/ProbeProcessInfo.cpp` +- `dbms/src/Interpreters/JoinPartition.cpp` +- `dbms/src/Interpreters/JoinHashMap.cpp` +- `dbms/src/DataStreams/ScanHashMapAfterProbeBlockInputStream.cpp` +- `dbms/src/Interpreters/Set.cpp` + +--- + +## 开发追踪 / Dev Note + +这一节放在文档后半部分,用于后续按 checkpoint 推进时记录实现进度。设计结论以前面的章节为准。 + +### How to continue + +继续开发前建议固定做三件事: + +1. 先读本文件的设计部分 +2. 再跑 `git status` / `git diff --stat` +3. 明确本次只推进哪个 checkpoint + +建议在后续指令里直接写: + +- “以 `docs/note/nulleq_join.md` 为准,从 CP2 开始继续” +- “先读设计文档,再读当前进度” + +### Milestone 划分 + +#### Milestone 0:协议 / Plumbing + +Done 标准: + +- TiFlash 能解析 `is_null_eq[]` +- 能透传到 `DB::Join` +- 未下发该字段时行为零变化 + +#### Milestone 1:正确性 MVP + +Done 标准: + +- nullable NullEQ key 能正确 build / probe +- mixed nullable / non-nullable 的 NullEQ key pair 能正确对齐 key schema 并命中 +- outer join / scan-after-probe 不把 NullEQ 的 `NULL` 行误判为 unmatched +- `FULL OUTER JOIN + other condition` 与 NullEQ 组合语义正确 +- runtime filter 在该模式下被禁用 + +#### Milestone 2:测试矩阵 + +Done 标准: + +- inner / left / right / full / semi / anti 的基础矩阵覆盖齐 +- mixed key、side-condition、spill/FGS 覆盖齐 + +#### Milestone 3:性能优化 + +Done 标准: + +- nullable fixed-size key 不再强制 serialized + +#### Milestone 4:RuntimeFilter(可选) + +Done 标准: + +- 单列 NullEQ key 的 runtime filter 语义正确,或明确长期禁用 + +### Checkpoint 建议 + +- CP0:tipb 字段 + TiFlash 解析 +- CP1:`DB::Join` 保存/打印 `is_null_eq` +- CP2.1:nullable NullEQ 强制 serialized + mixed-nullability key schema 对齐 + NullAware 互斥检查 +- CP2.2:build/probe 的 row_filter_map 语义拆分 +- CP2.3:`RowsNotInsertToMap` / scan-after-probe 调整 +- CP2.4:`FULL OUTER JOIN + other condition` 与 NullEQ 联动自测 +- CP2.5:MVP 禁用 runtime filter +- CP3:补测试 +- CP4:packed keys 优化 + +### 当前进度 + +- 说明:以下勾选按当前 workspace 核对,用于记录本轮开发推进状态。 +- [x] tipb: `Join.is_null_eq` 字段定义 +- [x] TiFlash: `JoinInterpreterHelper::TiFlashJoin` 解析 `is_null_eq[]` +- [x] TiFlash: `DB::Join` 保存/打印 `is_null_eq` +- [x] TiFlash: nullable NullEQ 强制 serialized + mixed-nullability key schema 对齐 + NullAware 互斥 fail-fast +- [x] TiFlash: build/probe 的 row_filter_map 语义拆分 +- [x] TiFlash: `RowsNotInsertToMap` / scan-after-probe 调整 +- [x] TiFlash: `FULL OUTER JOIN + other condition` 与 NullEQ 联动验证 +- [x] TiFlash: runtime filter 禁用 +- [x] TiFlash: gtest 已覆盖 inner / left / right / full / semi / anti 基础矩阵 +- [x] TiFlash: gtest 已覆盖 mixed key 与 side-condition 交互 +- [x] TiFlash: spill / fine-grained shuffle 测试覆盖 +- [x] TiFlash: packed keys 优化(nullable fixed-size NullEQ key 可走 `nullable_keys128/256`,其余场景回退 `serialized`) + +### Open Questions + +- TiDB / kvproto 何时同步 `is_null_eq[]` +- key 若未来允许表达式,`is_null_eq[i]` 如何稳定对齐 +- string + collation 的性能回退是否可接受 +- spill / FGS 场景下是否需要单独的 profile 或 debug 指标 +- NullAware join 是否永远与 NullEQ 互斥,还是未来要定义组合语义 From c216966989cda81fef74ca8c8515a2eef6bcae31 Mon Sep 17 00:00:00 2001 From: xufei Date: Wed, 19 Aug 2026 10:54:16 +0800 Subject: [PATCH 02/11] Remove unrelated json object changes from null-eq join port Signed-off-by: xufei --- dbms/src/Functions/FunctionsJson.h | 8 -------- dbms/src/Functions/tests/gtest_json_object.cpp | 9 --------- 2 files changed, 17 deletions(-) diff --git a/dbms/src/Functions/FunctionsJson.h b/dbms/src/Functions/FunctionsJson.h index d9579efa5be..a2aa587714b 100644 --- a/dbms/src/Functions/FunctionsJson.h +++ b/dbms/src/Functions/FunctionsJson.h @@ -1060,14 +1060,6 @@ class FunctionJsonObject : public IFunction std::vector nullmaps; nullmaps.reserve(sources.size()); bool is_input_nullable = false; - for (const auto & source : sources) - { - if (source == nullptr) - { - is_input_nullable = true; - break; - } - } for (auto column_number : arguments) { const auto & col = block.getByPosition(column_number).column; diff --git a/dbms/src/Functions/tests/gtest_json_object.cpp b/dbms/src/Functions/tests/gtest_json_object.cpp index 6eccdddf3e4..8e09484fc5f 100644 --- a/dbms/src/Functions/tests/gtest_json_object.cpp +++ b/dbms/src/Functions/tests/gtest_json_object.cpp @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include #include #include #include @@ -100,14 +99,6 @@ try ASSERT_COLUMN_EQ(expect, res); } - { - ColumnsWithTypeAndName inputs{ - createConstColumn(rows_count, "a"), - createOnlyNullColumnConst(rows_count), - }; - auto res = executeFunctionWithCast({0, 1}, inputs); - ASSERT_COLUMN_EQ(createConstColumn>(rows_count, R"({"a": null})"), res); - } } CATCH From 278ba0c0503bfb7538ec941d4cc4b31a6d90f9ad Mon Sep 17 00:00:00 2001 From: xufei Date: Wed, 19 Aug 2026 10:55:19 +0800 Subject: [PATCH 03/11] Remove json test-only whitespace from null-eq join port Signed-off-by: xufei --- dbms/src/Functions/tests/gtest_json_object.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/dbms/src/Functions/tests/gtest_json_object.cpp b/dbms/src/Functions/tests/gtest_json_object.cpp index 8e09484fc5f..2e56b109588 100644 --- a/dbms/src/Functions/tests/gtest_json_object.cpp +++ b/dbms/src/Functions/tests/gtest_json_object.cpp @@ -98,7 +98,6 @@ try auto expect = createColumn>({R"({"dup": 2})", R"({"dup": 3})"}); ASSERT_COLUMN_EQ(expect, res); } - } CATCH From 6f41df3b9b5d3325ffecb98e4f06e1596a9ab4bc Mon Sep 17 00:00:00 2001 From: xufei Date: Wed, 19 Aug 2026 11:13:09 +0800 Subject: [PATCH 04/11] save work Signed-off-by: xufei --- docs/note/nulleq_join.md | 807 ++++++++++++++++++++------------------- 1 file changed, 424 insertions(+), 383 deletions(-) diff --git a/docs/note/nulleq_join.md b/docs/note/nulleq_join.md index 56211d59e0f..32100c22cae 100644 --- a/docs/note/nulleq_join.md +++ b/docs/note/nulleq_join.md @@ -1,500 +1,533 @@ -# TiFlash NullEQ Join Key(`<=>` / `tidbNullEQ`)设计文档 +# TiFlash NullEQ Join Key (`<=>` / `tidbNullEQ`) Design -## 背景 +## Background -TiFlash 当前 Hash Join 的默认等值语义是: +The default equality semantics of TiFlash hash join are currently: -- `NULL` 不参与等值匹配 -- build 侧含 `NULL` key 的行不会进入 hash map -- probe 侧含 `NULL` key 的行会被直接当作 not matched +- `NULL` does not participate in equality matching. +- Build-side rows with a `NULL` key are not inserted into the hash map. +- Probe-side rows with a `NULL` key are treated directly as not matched. -这与 Null-safe equal(`<=>` / `tidbNullEQ`)要求的语义不同: +This differs from the semantics required by null-safe equality (`<=>` / `tidbNullEQ`): -- `NULL <=> NULL` 为 `true` -- `NULL <=> non-NULL` 为 `false` -- `non-NULL <=> non-NULL` 与普通 `=` 一致 +- `NULL <=> NULL` is `true`. +- `NULL <=> non-NULL` is `false`. +- `non-NULL <=> non-NULL` has the same semantics as ordinary `=`. -本设计文档讨论的是:在 TiFlash 已经支持 `FULL OUTER JOIN` 的前提下,如何为 hash join 增加 **join key 粒度的 NullEQ 语义**。 +This document discusses how to add **join-key-level NullEQ semantics** to hash join, +assuming that TiFlash already supports `FULL OUTER JOIN`. -## 目标 +## Goals -1. TiFlash 的 Hash Join 在 **join key** 使用 NullEQ 语义时结果正确。 -2. 支持 **key 粒度混合语义**: - - 同一个 join 中允许部分 key 走 `=` - - 允许部分 key 走 `<=>` -3. 未下发 NullEQ 标记时,保持现有行为不变。 -4. 与已经支持的 `FULL OUTER JOIN` 语义兼容,不引入 `NULL <=> NULL` 相关的 outer join 错误结果。 +1. Make TiFlash hash join produce correct results when a **join key** uses NullEQ semantics. +2. Support **mixed semantics at key granularity**: + - Some keys in the same join may use `=`. + - Other keys may use `<=>`. +3. Preserve the existing behavior when no NullEQ flags are sent. +4. Remain compatible with the existing `FULL OUTER JOIN` semantics and avoid incorrect + results related to `NULL <=> NULL` matching. -## 非目标 +## Non-goals -1. 不把 `other_conditions` 中的 `<=>` 纳入本文新增的 join-key NullEQ 语义范围;若 planner 下发了这类表达式,按普通 other condition 处理。 -2. MVP 不追求最优性能,允许为了正确性强制走 `serialized`。 -3. 不扩大 NullAware join(`NOT IN` 家族)的语义覆盖范围;MVP 阶段建议 fail-fast。 -4. 不在本轮实现 cartesian full join 与 NullEQ 的组合。 +1. `<=>` in `other_conditions` is outside the new join-key NullEQ semantics covered by + this document. If the planner sends such an expression, it is handled as an ordinary + other condition. +2. The MVP does not pursue optimal performance and may force the `serialized` method for + correctness. +3. The MVP does not expand the semantic coverage of NullAware joins (`NOT IN` family). + Fail-fast is recommended at the MVP stage. +4. The combination of cartesian full join and NullEQ is not implemented in this iteration. -## 作用范围 +## Scope -本轮 scope 限定为: +The scope of this iteration is limited to: -- hash join -- `left_join_keys/right_join_keys` 非空 -- NullEQ 只出现在 join key 上 +- Hash join. +- Non-empty `left_join_keys/right_join_keys`. +- NullEQ appearing only on join keys. -不包含: +The following cases are not included: -- cartesian join -- 仅通过 `other_conditions` 表达 NullEQ join 语义的计划形态 -- planner 把 `<=>` 重写成其它表达式后再由 TiFlash 反推语义 +- Cartesian join. +- Plan shapes where NullEQ join semantics are expressed only through `other_conditions`. +- Plans where the planner rewrites `<=>` into another expression and expects TiFlash to + infer the original semantics. -## 输入契约 +## Input Contract -### tipb 协议 +### tipb Protocol -建议在 `tipb::Join` 中增加: +The recommended change is to add the following field to `tipb::Join`: - `repeated bool is_null_eq = ...;` -语义如下: +The semantics are: -- `is_null_eq[i] = false`:第 `i` 对 join key 使用普通 `=` -- `is_null_eq[i] = true`:第 `i` 对 join key 使用 `<=>` +- `is_null_eq[i] = false`: the `i`-th join-key pair uses ordinary `=`. +- `is_null_eq[i] = true`: the `i`-th join-key pair uses `<=>`. -长度约束: +Length constraints: -- `is_null_eq_size == 0`:视为全 `false`,兼容旧版本 -- 否则必须满足: - - `is_null_eq_size == left_join_keys_size` - - `is_null_eq_size == right_join_keys_size` +- `is_null_eq_size == 0`: treat all entries as `false` for backward compatibility. +- Otherwise, all of the following must hold: + - `is_null_eq_size == left_join_keys_size`. + - `is_null_eq_size == right_join_keys_size`. -### Join key 表达形式 +### Join-Key Representation -MVP 假设 join key 由 planner 下发为列引用: +The MVP assumes that the planner sends join keys as column references: -- `left_join_keys[i]` 与 `right_join_keys[i]` 是一一对齐的 key pair -- TiFlash 如需在执行层插入 cast 做类型对齐,不改变 key 的顺序和数量 -- `is_null_eq[i]` 始终按 key pair index 对齐,而不是按 build/probe 角色对齐 +- `left_join_keys[i]` and `right_join_keys[i]` are aligned key pairs. +- TiFlash may insert casts in the execution layer to align types, but must not change the + order or number of keys. +- `is_null_eq[i]` is always aligned by key-pair index, not by the build/probe role. -### 语义边界 +### Semantic Boundary -NullEQ 语义只通过 `is_null_eq[]` 表达: +NullEQ semantics are expressed only through `is_null_eq[]`: -- 若 `<=>` 出现在 `other_conditions` 中,则按普通布尔表达式处理;本文不要求执行层从 `other_conditions` 里的 `<=>` 反推出“这是 join key NullEQ” -- 不让执行层从通用表达式里反推某个 key 是否是 NullEQ +- If `<=>` appears in `other_conditions`, it is handled as an ordinary boolean expression. + The execution layer is not required to infer join-key NullEQ from `<=>` inside + `other_conditions`. +- The execution layer must not infer whether a key is NullEQ from a generic expression. -### 与 NullAware join 的关系 +### Relationship with NullAware Join -`is_null_aware_semi_join` 与 NullEQ 是两套不同语义: +`is_null_aware_semi_join` and NullEQ represent different semantics: -- NullAware join 关注 `NOT IN` 的三值逻辑 -- NullEQ 关注 join key 的比较语义 +- NullAware join handles three-valued logic for `NOT IN`. +- NullEQ handles comparison semantics for join keys. -MVP 建议: +The MVP recommends the following behavior: -- 若 `is_null_aware_semi_join=true` 且存在任意 `is_null_eq[i]=true`,直接 fail-fast +- If `is_null_aware_semi_join=true` and any `is_null_eq[i]=true`, fail fast. -原因是这两条路径都对“NULL key 行怎么处理”有强假设,混用很容易产生 silent wrong result。 +Both paths make strong assumptions about how rows with `NULL` keys are handled, so mixing +them can easily produce silent wrong results. -## 当前实现的关键假设 +## Key Assumptions in the Existing Implementation -当前 Join 框架里,与 NullEQ 直接冲突的假设主要有四类。 +There are four main assumptions in the current join framework that directly conflict with +NullEQ. -### 1. key-NULL 会被提前过滤 +### 1. Key-NULL Rows Are Filtered Early -当前 build/probe 都会把 nullable key 做两件事: +The current build and probe paths process nullable keys in two steps: -1. 把 `ColumnNullable` 替换成 nested column -2. 把 key 中的 `NULL` 行写入 `null_map` +1. Replace `ColumnNullable` with its nested column. +2. Write rows containing `NULL` keys into `null_map`. -对应路径: +The relevant paths are: -- build:`Join::insertFromBlockInternal()` -- probe:`ProbeProcessInfo::prepareForHashProbe()` +- Build: `Join::insertFromBlockInternal()`. +- Probe: `ProbeProcessInfo::prepareForHashProbe()`. -这意味着: +This means: -- build 侧 `NULL` key 行默认不入 map -- probe 侧 `NULL` key 行默认不 probe map +- Build-side rows with `NULL` keys are not inserted into the map by default. +- Probe-side rows with `NULL` keys do not probe the map by default. -这与 NullEQ 的 `NULL <=> NULL` 可以匹配直接冲突。 +That directly conflicts with the fact that `NULL <=> NULL` must be matchable. -### 2. side-condition 与 key-NULL 共用一张 null_map +### 2. Side Conditions and Key-NULL Rows Share One null_map -当前 `recordFilteredRows()` 会复用同一张 `null_map`,把 side-condition 过滤结果与 “key 是否为 NULL” 混到一起。 +`recordFilteredRows()` currently reuses the same `null_map` to combine side-condition +results with the information about whether a key is `NULL`. -对 NullEQ 来说,问题不在于执行链路必须长期维护两张独立的 map,而在于: +The problem for NullEQ is not that the execution path must permanently maintain two +independent maps. The problem is that these two sources must be distinguished when the +final filter result is generated: -- 普通 `=` key 的 `NULL` 过滤 -- left/right side-condition 过滤 +- A `NULL` in an ordinary `=` key must be written to the final filter result. +- A `NULL` in a NullEQ key must not be written to the final filter result. +- Side-condition failures must always be written to the final filter result. -这两类来源在**生成过滤结果**时必须区分,因为: +A more accurate approach is: -- 对普通 `=` key,应把 key 的 `NULL` 写入最终过滤结果 -- 对 NullEQ key,不应把 key 的 `NULL` 写入最终过滤结果 -- side-condition 的过滤结果则始终需要写入最终过滤结果 +- Decide at key granularity which `NULL` values should participate in filtering. +- Merge those results with side-condition results into one unified `row_filter_map`. -因此更准确的做法是: +Therefore, the final implementation may still maintain only one map indicating whether a +row should be skipped, but it must not continue using the current approach of writing all +key-NULL rows into `null_map` before adding side-condition results. -- 先按 key 粒度决定哪些 `NULL` 需要参与过滤 -- 再与 side-condition 的过滤结果合并成一张统一的 `row_filter_map` +### 3. RowsNotInsertToMap and Scan-After-Probe Treat NULL Keys as Naturally Unmatched -也就是说,最终可以只有一张“这一行是否跳过 insert/probe”的 map,但不能继续沿用当前这种“先无差别把所有 key-NULL 都写进 null_map,再复用它叠加 side-condition”的实现方式。 +For join kinds that need to preserve special build-side rows, such as right/full/right +semi/right anti/null-aware joins, the current implementation records rows not inserted +into the map in `RowsNotInsertToMap`, then outputs them during scan-after-probe. -### 3. RowsNotInsertToMap / scan-after-probe 默认把 NULL key 当作天然 unmatched +This is valid under ordinary `=` semantics because rows with `NULL` keys do not match. -对于 right/full/right semi/right anti/null-aware 这些需要保留 build 侧特殊行的 join kind,当前实现会把“未入 map 的 build 行”记进 `RowsNotInsertToMap`,之后在 scan-after-probe 阶段输出。 +Under NullEQ semantics: -在普通 `=` 语义下这成立,因为 key-NULL 本来就不参与匹配。 +- A row with a `NULL` key is not necessarily unmatched. +- It may need to enter the map and match a probe-side row with a `NULL` key. -但在 NullEQ 语义下: +### 4. KeyGetter Does Not Encode the Nullable Bitmap by Default -- `NULL` key 行不一定是 unmatched -- 它可能应该入 map,并与 probe 侧 `NULL` key 行成功匹配 +Fixed-key hash methods such as `keys128/keys256` currently default to +`has_nullable_keys = false`. -### 4. KeyGetter 默认不编码 nullable bitmap +Consequently, even if `NULL` rows are not filtered early, the existing packed-key path +may still fail to encode nullness into the hash key correctly. -当前 `keys128/keys256` 这类 fixed key hash method 默认是 `has_nullable_keys = false`。 +## Additional Interaction with FULL OUTER JOIN -这意味着即使不提前过滤 `NULL`,现有 packed key 路径也未必能正确把 nullness 编进 hash key。 +NullEQ is not specific to `FULL OUTER JOIN`; `LEFT OUTER JOIN` and `RIGHT OUTER JOIN` +are affected as well. However, after TiFlash added `FULL OUTER JOIN`, several interactions +must be made explicit in the design. Otherwise, both sides can produce incorrect results. -## 与 FULL OUTER JOIN 的额外交互 +### 1. The Natural-Unmatched NULL-Key Path Is Not Full-Join Specific, but FULL Amplifies It -NullEQ 本身不是 `FULL OUTER JOIN` 专属问题,`LEFT OUTER JOIN` 和 `RIGHT OUTER JOIN` 也会受影响。 -但在 TiFlash 已经支持 `FULL OUTER JOIN` 之后,有几件事必须在设计里显式纳入,否则很容易出现双边都错的结果。 - -### 1. “NULL key 走天然 unmatched 路径”不是 full 特有问题,但 full 会把问题放大 - -这件事对不同 outer join 的影响不同: +The impact differs by join type: - `LEFT OUTER JOIN` - - probe 侧 `NULL` key 若仍直接走 `addNotFound()`,本该命中的 `NULL <=> NULL` 会被错误输出成左 unmatched + - If a probe-side `NULL` key still goes directly through `addNotFound()`, a row that + should match through `NULL <=> NULL` is incorrectly emitted as left unmatched. - `RIGHT OUTER JOIN` - - build 侧 `NULL` key 若仍进 `RowsNotInsertToMap`,本该命中的行会在 scan-after-probe 阶段被错误输出成右 unmatched + - If a build-side `NULL` key still enters `RowsNotInsertToMap`, a row that should match + is incorrectly emitted as right unmatched during scan-after-probe. - `FULL OUTER JOIN` - - 上述两条路径会同时存在 - - 一组本该匹配的 `NULL <=> NULL` 行,可能被错误拆成: - - 一条左 unmatched - - 一条右 unmatched + - Both paths are present at the same time. + - A group of rows that should match through `NULL <=> NULL` may be incorrectly split into: + - one left-unmatched row; + - one right-unmatched row. -所以这不是 full 独有问题,但 full 会把问题表现得最明显、也最复杂。 +This is not a full-join-only problem, but FULL makes the symptom most obvious and the +resulting behavior most complex. -### 2. FULL + other condition 必须继续沿用“延后 setUsed”语义 +### 2. FULL + Other Condition Must Continue Using Delayed setUsed Semantics -当前 full 分支已经为 `full + other condition` 做了专门修正: +The current full-join path has a dedicated correction for `full + other condition`: -- key 命中时不能立刻 `setUsed()` -- 必须等 `other condition` 真正通过后,再标记 build 行为 used +- Do not call `setUsed()` immediately when the join key matches. +- Mark the build row as used only after the other condition actually passes. -否则 probe 后扫描阶段会漏输出本应作为 unmatched build 行的记录。 +Otherwise, scan-after-probe may omit a build row that should be emitted as unmatched. -在 NullEQ 引入后,这个约束仍然成立,而且要覆盖 `NULL <=> NULL` 命中的情况: +After introducing NullEQ, this constraint still applies to `NULL <=> NULL` matches: -- key 通过是因为 `NULL <=> NULL` -- other condition 失败 -- 正确语义应该是: - - 左侧保留一条右补 null 的 unmatched 行 - - 右侧 build 行仍然在后扫阶段输出为 unmatched +- The key matches because `NULL <=> NULL`. +- The other condition fails. +- The correct result is: + - keep one left-unmatched row with right-side NULLs; + - emit the build row as unmatched during the later scan. -因此 NullEQ 不能绕开 full 分支当前的 row-flagged / delayed-used 设计。 +Therefore, NullEQ must continue using the existing row-flagged and delayed-used design +for the full-join path. -### 3. RowsNotInsertToMap 在 full 下要重新定义语义 +### 3. RowsNotInsertToMap Must Be Redefined for FULL -在支持 full 之后,`RowsNotInsertToMap` 不能再简单理解成“所有 NULL key 行 + 所有 build condition 失败的行”。 +After FULL support, `RowsNotInsertToMap` can no longer simply mean "all NULL-key rows +plus all rows that failed the build condition". -更准确的语义应该是: +Its more precise meaning should be: -- build side-condition 失败的行 -- 普通 `=` key 因 key-NULL 被过滤的行 +- rows that failed a build-side condition; +- rows filtered because an ordinary `=` key contained `NULL`. -不应包含: +It must not include: -- NullEQ key 为 `NULL` 的行 +- rows whose NullEQ key is `NULL`. -因为这些行应该入 map,并可能成功匹配。 +Those rows should enter the map and may match successfully. -### 4. dispatch hash / spill / fine-grained shuffle 在 full 下更容易暴露错误 +### 4. Dispatch Hash, Spill, and Fine-Grained Shuffle Expose More Errors Under FULL -如果 build/probe 的 dispatch hash 没有把 nullness 编进 key: +If dispatch hashing does not encode nullness into the key: -- build 侧 `NULL` key 与 probe 侧 `NULL` key 可能落到不同 partition -- inner join 下通常表现为“不命中” -- full join 下则可能进一步演变成: - - probe 侧输出一条 unmatched - - build 侧后扫再输出一条 unmatched +- Build-side `NULL` keys and probe-side `NULL` keys may be sent to different partitions. +- An inner join usually appears simply as a missing match. +- Under FULL, it may additionally become: + - one unmatched row from the probe side; + - one unmatched row from the build-side scan. -所以 `full + NullEQ + spill/FGS` 应该是 MVP 测试矩阵里的必测项,而不是后续补充项。 +Therefore, `full + NullEQ + spill/FGS` must be part of the MVP test matrix rather than +being deferred to a later iteration. -### 5. full 的 schema nullable 规则不需要为 NullEQ 再单独扩展 +### 5. FULL Schema Nullability Does Not Need a Separate NullEQ Extension -这一点反而不用新增复杂度: +No additional complexity is needed here: -- full 输出 schema 两边本来就都应为 nullable -- other-condition 输入 schema 两边也已经按 full 语义处理成 nullable +- Both sides of a FULL output schema should already be nullable. +- Input schemas for other conditions are already made nullable according to FULL semantics. -NullEQ 改变的是 **匹配语义**,不是 full 输出 schema 的 nullable 规则。 +NullEQ changes the **matching semantics**, not the nullable rules for the FULL output schema. -## 设计选择 +## Design Choices -## 1. 总体原则 +## 1. General Principles -NullEQ 设计遵循两个核心原则: +The NullEQ design follows two core principles: -1. 把“key 是否为 NULL”与“row 是否因 side-condition 被过滤”分离 -2. 让 NullEQ 的 `NULL` 真正进入 key 比较,而不是继续被当成特殊 unmatched 行 +1. Separate "whether a key is NULL" from "whether a row is filtered by a side condition". +2. Make the `NULL` value of a NullEQ key participate in key comparison instead of treating it + as a special unmatched row. -由此得到两个概念: +This leads to two concepts: - `row_filter_map` - - 表示这一行不需要 insert/probe - - 原因可以是 left/right condition 失败,也可以是普通 `=` key 的 `NULL` + - Indicates that a row should not be inserted or probed. + - The reason may be a failed left/right condition or a `NULL` in an ordinary `=` key. - `key_null_map` - - 只对普通 `=` key 有意义 - - NullEQ key 不应把 `NULL` 写进这张 map - -这里保留这两个名字,主要是为了说明“过滤结果的来源”。 + - Meaningful only for ordinary `=` keys. + - A NullEQ key must not write `NULL` into this map. -最终实现里,它们完全可以合并成一张统一的 `row_filter_map`: +The names are retained to describe the source of each filtering result. -- 普通 `=` key 的 `NULL` 可以进入这张 map -- left/right side-condition 的过滤结果也进入这张 map -- 但 NullEQ key 的 `NULL` 不能进入这张 map +In the final implementation, these concepts may be represented by one unified +`row_filter_map`: -换句话说,关键不是最终一定要维护两张独立的 map,而是生成最终过滤结果时,必须按 key 粒度决定哪些 `NULL` 应该被当作“跳过 insert/probe”的条件。 +- `NULL` in an ordinary `=` key may be written to this map. +- Left/right side-condition results may also be written to this map. +- `NULL` in a NullEQ key must not be written to this map. -## 2. build/probe 都按 key 粒度区分 `=` 与 `<=>` +In other words, the key requirement is not that two independent maps must always be +maintained. The key requirement is that generating the final filter result must determine +at key granularity which `NULL` values mean "skip insert/probe". -对于每个 key pair: +## 2. Distinguish `=` and `<=>` by Key -- 若 `is_null_eq[i] = false` - - 延续现有 `=` 语义 - - key 中有 `NULL` 时,这一行不参与匹配 -- 若 `is_null_eq[i] = true` - - 保留 nullable key - - `NULL` 可以参与 hash / probe / match +For each key pair: -也就是说,NullEQ 不是“整条 join 全都变 null-safe”,而是按 key pair 生效。 +- If `is_null_eq[i] = false`: + - Preserve the existing `=` semantics. + - A `NULL` in any key component means that the row does not participate in matching. +- If `is_null_eq[i] = true`: + - Preserve the nullable key. + - `NULL` may participate in hashing, probing, and matching. -## 3. build 路径设计 +NullEQ does not make the entire join null-safe. It takes effect independently for each +key pair. -build 阶段的目标是: +## 3. Build-Path Design -- NullEQ key 为 `NULL` 的行可以入 map -- 普通 `=` key 为 `NULL` 的行仍不入 map -- side-condition 失败的行不入 map,但 outer join 语义所需的保底输出仍要保留 +The build path must satisfy the following: -建议做法: +- A row with a `NULL` NullEQ key may enter the map. +- A row with a `NULL` ordinary `=` key must not enter the map. +- A row that fails a side condition must not enter the map, while any fallback output + required by outer-join semantics must still be preserved. -1. 从原始 key columns 出发,不再无条件对所有 key 调用 `extractNestedColumnsAndNullMap` -2. 遍历每个 key: - - 对 `=` key: - - 若是 nullable,则取 nested column - - 并把该列 null map OR 进 `row_filter_map` - - 对 `<=>` key: - - 保留 `ColumnNullable` - - 不把该列 null map 写进 `row_filter_map` -3. 再把 build side-condition 的过滤结果 OR 进 `row_filter_map` -4. 传给 `JoinPartition::insertBlockIntoMaps(..., row_filter_map, ...)` +Recommended approach: -这样 build 路径上的语义就变成: +1. Start from the original key columns instead of unconditionally calling + `extractNestedColumnsAndNullMap()` for every key. +2. Process each key: + - For an `=` key: + - If it is nullable, use its nested column. + - OR its null map into `row_filter_map`. + - For a `<=>` key: + - Preserve `ColumnNullable`. + - Do not write its null map into `row_filter_map`. +3. OR build-side condition results into `row_filter_map`. +4. Pass `row_filter_map` to `JoinPartition::insertBlockIntoMaps(..., row_filter_map, ...)`. -- `row_filter_map[i] = 1` - - 这一行不入 map -- `row_filter_map[i] = 0` - - 这一行入 map +The build-path semantics then become: -对于 full/right outer/right semi/right anti 这些会记录 build 特殊行的 join kind: +- `row_filter_map[i] = 1`: do not insert this row into the map. +- `row_filter_map[i] = 0`: insert this row into the map. -- 只有 side-condition 失败的行、或者普通 `=` key 的 `NULL` 行,才进入 `RowsNotInsertToMap` -- NullEQ key 的 `NULL` 行不应进入 `RowsNotInsertToMap` +For join kinds that record special build-side rows, such as full/right outer, right semi, +and right anti: -## 4. probe 路径设计 +- Only rows that failed a side condition or contain `NULL` in an ordinary `=` key should + enter `RowsNotInsertToMap`. +- Rows with `NULL` in a NullEQ key must not enter `RowsNotInsertToMap`. -probe 阶段的目标是: +## 4. Probe-Path Design -- NullEQ key 为 `NULL` 的行可以真正 probe map -- 普通 `=` key 的 `NULL` 行仍然按“不匹配”处理 -- left/full outer 语义下,probe unmatched 的保底输出仍然正确 +The probe path must satisfy the following: -建议做法与 build 对称: +- A row with a `NULL` NullEQ key may actually probe the map. +- A row with a `NULL` ordinary `=` key remains a non-matching row. +- Fallback output for probe-side unmatched rows remains correct for left/full outer semantics. -1. 不再无条件对所有 key 做 `extractNestedColumnsAndNullMap` -2. 逐 key 处理: - - `=` key 的 `NULL` 写入 `row_filter_map` - - `<=>` key 保留 nullable,不写入 `row_filter_map` -3. 再把 probe side-condition 的过滤结果 OR 进 `row_filter_map` -4. probe 时: - - `row_filter_map[i] = 1` 的行继续走历史 unmatched 路径 - - `row_filter_map[i] = 0` 的行真正进入 hash probe +The recommended approach mirrors the build path: -这条规则对 outer join 的影响是: +1. Do not unconditionally call `extractNestedColumnsAndNullMap()` for every key. +2. Process each key: + - Write `NULL` from an `=` key into `row_filter_map`. + - Preserve a nullable `<=>` key and do not write its `NULL` into `row_filter_map`. +3. OR probe-side condition results into `row_filter_map`. +4. During probing: + - Rows with `row_filter_map[i] = 1` continue through the historical unmatched path. + - Rows with `row_filter_map[i] = 0` actually probe the hash map. -- `LEFT OUTER JOIN` 不会再把 NullEQ 的 `NULL` probe 行过早打成 unmatched -- `FULL OUTER JOIN` 同理,但还要与后扫 build unmatched 语义一起对齐 +The effects on outer joins are: -## 5. Hash key 编码策略 +- `LEFT OUTER JOIN` no longer prematurely treats a probe-side NullEQ `NULL` row as unmatched. +- `FULL OUTER JOIN` follows the same rule, while also aligning with build-side + scan-after-probe unmatched semantics. -### 当前实现 +## 5. Hash-Key Encoding Strategy -当存在 **nullable 的 NullEQ key** 时,当前 Join map method 的选择规则是: +### Current Implementation -- 若参与编码的 key columns 都是 fixed-size,且 `null bitmap + payload` 能放进 `UInt128/UInt256` - - 分别走 `JoinMapMethod::nullable_keys128` / `JoinMapMethod::nullable_keys256` -- 其它情况继续回退到 `JoinMapMethod::serialized` +When a nullable NullEQ key is present, the current Join map-method selection is: -fixed-size 路径复用了 HashAgg / Set 已有的 nullable packed keys 思路: +- If all participating key columns are fixed-size and the `null bitmap + payload` fits in + `UInt128/UInt256`: + - use `JoinMapMethod::nullable_keys128` or `JoinMapMethod::nullable_keys256`; +- otherwise, fall back to `JoinMapMethod::serialized`. -- `keys128/keys256 + has_nullable_keys = true` -- 把 nullness bitmap 与 key payload 一起编码进 packed key +The fixed-size path reuses the nullable packed-key approach already used by HashAgg/Set: -这样常见的 nullable numeric / datetime NullEQ join 不必再一律退化到 `serialized`。 +- `keys128/keys256 + has_nullable_keys = true`; +- encode the nullness bitmap together with the key payload in the packed key. -`serialized` 仍然保留为正确性兜底: +This means common nullable numeric/datetime NullEQ joins do not always need to fall back +to `serialized`. -- 变长 key 仍可自然保留 `ColumnNullable` 的 nullness -- fixed-size key 若带 bitmap 后放不进 `UInt256`,仍可继续工作 +`serialized` remains the correctness fallback: -但这里有一个必须显式满足的前提: +- Variable-length keys can naturally preserve the nullness of `ColumnNullable`. +- Fixed-size keys whose bitmap does not fit in `UInt256` can still use `serialized`. -- `serialized` 只是在“当前列对象长什么样”这个层面保留 nullness -- 它不会自动把 `Nullable(T)` 与 `T` 归一成同一种物理编码 +There is one prerequisite that must be satisfied explicitly: -当前 `ColumnNullable::serializeValueIntoArena()` 会先写入 null flag,再写 nested value。 -因此对于同一个非空值: +- `serialized` preserves nullness based on the current column object. +- It does not automatically normalize `Nullable(T)` and `T` to the same physical encoding. -- `Nullable(Int32)` 的序列化结果 -- `Int32` 的序列化结果 +`ColumnNullable::serializeValueIntoArena()` writes a null flag first and then the nested +value. Therefore, for the same non-NULL value: -并不相同。 +- the serialized representation of `Nullable(Int32)`; +- and the serialized representation of `Int32`; -这意味着: +are different. -- 若某个 NullEQ key pair 一侧是 nullable、另一侧是 non-nullable -- 即使 build/probe 两边都走 `serialized` -- 只要两边最终 key schema 仍分别是 `Nullable(T)` 与 `T` -- 相同的非空值也可能 hash / probe 不命中 +This means that if one side of a NullEQ key pair is nullable and the other is non-nullable, +both sides may use `serialized` and still fail to match, as long as their final key schemas +remain `Nullable(T)` and `T`. -因此 MVP 不能只做“nullable NullEQ 强制 serialized”,还必须保证: +Therefore, the MVP cannot merely force nullable NullEQ keys to `serialized`. It must also +ensure that: -- 对每个 `is_null_eq[i] = true` 的 key pair -- 只要任一侧最终需要保留 nullable 语义 -- build/probe 两边就必须在 prepare key 阶段对齐到同一个物理 key schema -- 最直接的做法是统一到 `Nullable(common_type)` +- for each `is_null_eq[i] = true` key pair; +- whenever either side needs to preserve nullable semantics; +- both build and probe sides are aligned to the same physical key schema during key preparation; +- the most direct approach is to normalize both sides to `Nullable(common_type)`. -这一步最初属于 `serialized` 正确性兜底的一部分;在 fixed-size packed key 优化落地后,这个 schema 对齐约束仍然需要继续保持。 +This schema-alignment requirement was initially part of the `serialized` correctness fallback. +It remains necessary after the fixed-size packed-key optimization is introduced. -## 6. JoinPartition / KeyGetter 语义 +## 6. JoinPartition / KeyGetter Semantics -NullEQ 真正落地到 JoinPartition 时,关键不是“有没有 nullable 列”,而是: +When implementing NullEQ in JoinPartition, the important question is not simply whether +the column is nullable. The key getter must be able to encode nullness into the key. -- key getter 能不能把 nullness 编进 key +The current JoinPartition has explicit nullable-aware fixed-key KeyGetter branches: -当前 JoinPartition 已显式引入 nullable-aware 的 fixed-key KeyGetter 分支: +- `nullable_keys128 -> HashMethodKeysFixed<..., UInt128, ..., true, false>`; +- `nullable_keys256 -> HashMethodKeysFixed<..., UInt256, ..., true, false>`. -- `nullable_keys128 -> HashMethodKeysFixed<..., UInt128, ..., true, false>` -- `nullable_keys256 -> HashMethodKeysFixed<..., UInt256, ..., true, false>` +The semantics are: -对应语义是: +- The packed-key path includes nullness in the key. +- Variable-length keys or fixed-size keys that exceed `UInt256` continue to use `serialized`. -- packed key 路径会把 nullness bitmap 编进 key -- 变长 key 或超出 `UInt256` 的 fixed-size key 仍走 `serialized` +Regardless of the selected path, the following must hold: -无论走哪条路径,仍要保证: +- Key columns passed by build and probe preserve nullable information for NullEQ keys. +- For every NullEQ key pair, the final key schemas used by build and probe are identical. +- In particular, mixed nullable/non-nullable cases must not remain as `Nullable(T)` versus `T`. -- build/probe 传进来的 key columns 保留了 NullEQ key 的 nullable 信息 -- 对任意 NullEQ key pair,build/probe 两边最终参与编码的 key schema 一致 -- 尤其是 mixed nullable / non-nullable 的场景,不能保留成 `Nullable(T)` 对 `T` +## 7. FULL + Other Condition Semantics -## 7. FULL + other condition 语义 +Because the full-join path already has row-flagged logic, NullEQ does not need a separate +FULL semantic path. It must ensure that a NullEQ key match goes through the existing correct +path: -由于 full 分支已经有 row-flagged 逻辑,NullEQ 这里的要求不是新增一套 full 语义,而是确保 NullEQ key 命中也走已有正确链路: +1. For `full + other condition`: + - continue using the row-flagged map; +2. If the key matches but the other condition fails: + - delay marking the build row as used until the other condition passes; +3. Apply this rule to: + - ordinary-value matches; + - `NULL <=> NULL` matches. -1. 对 `full + other condition`: - - 继续使用 row-flagged map -2. 对 key 命中但 other condition 失败的场景: - - build 行的 used 标记必须延后到 other condition 通过后 -3. 这个规则必须覆盖: - - 普通值命中 - - `NULL <=> NULL` 命中 - -否则 full 下会出现漏右行或重复 unmatched 行。 +Otherwise, FULL may omit a build row or emit duplicate unmatched rows. ## 8. RuntimeFilter -MVP 建议: - -- 只要 join 含 NullEQ,且存在 nullable 的 NullEQ key,就禁用 runtime filter - -原因: +The MVP recommends: -- 当前 runtime filter / Set 路径仍然默认丢弃 `NULL` key -- 这与 NullEQ 的 “NULL 可以匹配” 冲突 +- Disable runtime filters whenever the join contains NullEQ and at least one nullable + NullEQ key. -更长期的方向可以是: +The reason is that the current runtime-filter/Set path still drops `NULL` keys by default, +which conflicts with the NullEQ rule that `NULL` may match. -- Set 里额外维护 `has_null` -- 单列 NullEQ key 的 runtime filter 应用语义改成: - - `isNull(x) ? has_null : (x IN set)` +A possible long-term direction is: -但这不建议放进 MVP。 +- maintain an additional `has_null` flag in Set; +- apply a single-column NullEQ runtime filter as: + - `isNull(x) ? has_null : (x IN set)`. -## 9. 备选方案:Planner 重写 `<=>` +This is not recommended for the MVP. -另一条路线是让 TiDB planner 不显式下发 `is_null_eq[]`,而是把每个 `<=>` key 重写成: +## 9. Alternative: Rewrite `<=>` in the Planner -1. `isNull(k)` -2. `ifNull(k, sentinel)` +Another approach is for the TiDB planner not to send explicit `is_null_eq[]`, but to rewrite +each `<=>` key into: -这样 TiFlash 仍然走普通 `=` join。 +1. `isNull(k)`; +2. `ifNull(k, sentinel)`. -这条路线的优点是执行层改动小,但缺点也很明显: +TiFlash could then continue using ordinary `=` joins. -- 每个 `<=>` key 变成两个 key -- hash key 变宽 -- planner/runtime filter/cast/collation 都会更绕 -- key 级别语义变得不够直观 +The advantage is that execution-layer changes would be smaller. The disadvantages are clear: -因此本设计默认选择: +- Every `<=>` key becomes two keys. +- Hash keys become wider. +- Planner, runtime filter, cast, and collation handling become more complicated. +- Key-level semantics become less explicit. -- TiFlash 原生支持 key 粒度 NullEQ +Therefore, this design chooses native key-level NullEQ support in TiFlash. -## 测试建议 +## Testing Recommendations -MVP 至少应覆盖: +The MVP should cover at least: -1. `INNER JOIN` - - `NULL <=> NULL` 命中 - - `NULL <=> 1` 不命中 -2. `LEFT OUTER JOIN` - - probe 侧 `NULL` key 不会被过早当作 unmatched -3. `RIGHT OUTER JOIN` - - build 侧 `NULL` key 不会被错误塞进 `RowsNotInsertToMap` -4. `FULL OUTER JOIN` - - `NULL <=> NULL` 命中时,不会被拆成两条 unmatched - - `NULL <=> NULL` 命中但 `other condition` 失败时,左右 unmatched 都正确 -5. `SEMI / ANTI` - - `NULL <=> NULL` 参与存在性判断 -6. 多列混合语义 - - `k1 <=> k1 AND k2 = k2` - - `k1 <=> k1 AND k2 <=> k2` -7. side-condition 交互 - - left/right condition 与 NullEQ key 共存 -8. spill / fine-grained shuffle - - 特别是 `FULL OUTER JOIN + NullEQ` +1. `INNER JOIN`: + - `NULL <=> NULL` matches; + - `NULL <=> 1` does not match. +2. `LEFT OUTER JOIN`: + - A probe-side `NULL` key is not prematurely treated as unmatched. +3. `RIGHT OUTER JOIN`: + - A build-side `NULL` key is not incorrectly placed in `RowsNotInsertToMap`. +4. `FULL OUTER JOIN`: + - A `NULL <=> NULL` match is not split into two unmatched rows. + - If a `NULL <=> NULL` key match fails the other condition, both unmatched sides are correct. +5. `SEMI / ANTI`: + - `NULL <=> NULL` participates in existence checks. +6. Multiple keys with mixed semantics: + - `k1 <=> k1 AND k2 = k2`; + - `k1 <=> k1 AND k2 <=> k2`. +7. Side-condition interaction: + - left/right conditions coexist with NullEQ keys. +8. Spill / fine-grained shuffle: + - especially `FULL OUTER JOIN + NullEQ`. -### CP3 测试补充进度 +### CP3 Test Progress -按当前 workspace 的进度,CP3 的 spill / FGS 链路已补齐,当前已覆盖: +Based on the current workspace progress, the CP3 spill/FGS paths are covered: -1. `spill + FULL OUTER JOIN + NullEQ` - - `NULL <=> NULL` 命中后不会被拆成两条 unmatched -2. `spill + FULL OUTER JOIN + NullEQ + other condition` - - 数据同时覆盖 `other condition = false/true` - - `other condition = false` 时,build 行仍会在 scan-after-probe 正确输出 - - `other condition = true` 时,build 行会被正常消费,不会再次输出 -3. `fine-grained shuffle + NullEQ` - - 覆盖了一组 nullable key - - 验证 build / probe 两侧 key schema 对齐后,probe 不会把 NullEQ 的 `NULL` 误判成 filtered / unmatched +1. `spill + FULL OUTER JOIN + NullEQ`: + - A `NULL <=> NULL` match is not split into two unmatched rows. +2. `spill + FULL OUTER JOIN + NullEQ + other condition`: + - Data covers both `other condition = false` and `other condition = true`. + - When `other condition = false`, the build row is still emitted correctly during + scan-after-probe. + - When `other condition = true`, the build row is consumed normally and is not emitted again. +3. `fine-grained shuffle + NullEQ`: + - Covers a nullable key. + - Verifies that after build/probe key-schema alignment, the probe does not incorrectly + classify a NullEQ `NULL` as filtered or unmatched. -## 代码热点 +## Code Hotspots - `dbms/src/Flash/Coprocessor/JoinInterpreterHelper.*` - `dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp` @@ -508,94 +541,102 @@ MVP 至少应覆盖: --- -## 开发追踪 / Dev Note +## Development Tracking / Dev Note -这一节放在文档后半部分,用于后续按 checkpoint 推进时记录实现进度。设计结论以前面的章节为准。 +This section is placed in the latter half of the document to record implementation progress +as development proceeds by checkpoint. The design conclusions in the preceding sections take +priority. -### How to continue +### How to Continue -继续开发前建议固定做三件事: +Before continuing development, it is recommended to always do three things: -1. 先读本文件的设计部分 -2. 再跑 `git status` / `git diff --stat` -3. 明确本次只推进哪个 checkpoint +1. Read the design section of this document first. +2. Run `git status` / `git diff --stat`. +3. Explicitly identify which checkpoint this change is intended to advance. -建议在后续指令里直接写: +Suggested wording for future instructions: -- “以 `docs/note/nulleq_join.md` 为准,从 CP2 开始继续” -- “先读设计文档,再读当前进度” +- "Use `docs/note/nulleq_join.md` as the source of truth and continue from CP2." +- "Read the design document first, then read the current progress." -### Milestone 划分 +### Milestones -#### Milestone 0:协议 / Plumbing +#### Milestone 0: Protocol / Plumbing -Done 标准: +Done criteria: -- TiFlash 能解析 `is_null_eq[]` -- 能透传到 `DB::Join` -- 未下发该字段时行为零变化 +- TiFlash can parse `is_null_eq[]`. +- The value is passed through to `DB::Join`. +- Behavior is unchanged when the field is not sent. -#### Milestone 1:正确性 MVP +#### Milestone 1: Correctness MVP -Done 标准: +Done criteria: -- nullable NullEQ key 能正确 build / probe -- mixed nullable / non-nullable 的 NullEQ key pair 能正确对齐 key schema 并命中 -- outer join / scan-after-probe 不把 NullEQ 的 `NULL` 行误判为 unmatched -- `FULL OUTER JOIN + other condition` 与 NullEQ 组合语义正确 -- runtime filter 在该模式下被禁用 +- Nullable NullEQ keys build and probe correctly. +- Mixed nullable/non-nullable NullEQ key pairs align their key schemas and match correctly. +- Outer join and scan-after-probe do not misclassify NullEQ `NULL` rows as unmatched. +- `FULL OUTER JOIN + other condition` has correct combined semantics with NullEQ. +- Runtime filters are disabled in this mode. -#### Milestone 2:测试矩阵 +#### Milestone 2: Test Matrix -Done 标准: +Done criteria: -- inner / left / right / full / semi / anti 的基础矩阵覆盖齐 -- mixed key、side-condition、spill/FGS 覆盖齐 +- The basic inner/left/right/full/semi/anti matrix is covered. +- Mixed keys, side conditions, and spill/FGS are covered. -#### Milestone 3:性能优化 +#### Milestone 3: Performance Optimization -Done 标准: +Done criteria: -- nullable fixed-size key 不再强制 serialized +- Nullable fixed-size keys no longer always fall back to `serialized`. -#### Milestone 4:RuntimeFilter(可选) +#### Milestone 4: RuntimeFilter (Optional) -Done 标准: +Done criteria: -- 单列 NullEQ key 的 runtime filter 语义正确,或明确长期禁用 +- Runtime-filter semantics for a single-column NullEQ key are correct, or long-term + disabling is explicitly documented. -### Checkpoint 建议 +### Suggested Checkpoints -- CP0:tipb 字段 + TiFlash 解析 -- CP1:`DB::Join` 保存/打印 `is_null_eq` -- CP2.1:nullable NullEQ 强制 serialized + mixed-nullability key schema 对齐 + NullAware 互斥检查 -- CP2.2:build/probe 的 row_filter_map 语义拆分 -- CP2.3:`RowsNotInsertToMap` / scan-after-probe 调整 -- CP2.4:`FULL OUTER JOIN + other condition` 与 NullEQ 联动自测 -- CP2.5:MVP 禁用 runtime filter -- CP3:补测试 -- CP4:packed keys 优化 +- CP0: tipb field and TiFlash parsing. +- CP1: `DB::Join` stores and logs `is_null_eq`. +- CP2.1: force nullable NullEQ keys to `serialized`, align mixed-nullability key schemas, + and add the NullAware mutual-exclusion check. +- CP2.2: split the build/probe `row_filter_map` semantics. +- CP2.3: adjust `RowsNotInsertToMap` and scan-after-probe. +- CP2.4: validate the interaction between `FULL OUTER JOIN + other condition` and NullEQ. +- CP2.5: disable runtime filters for the MVP. +- CP3: add tests. +- CP4: optimize packed keys. -### 当前进度 +### Current Progress -- 说明:以下勾选按当前 workspace 核对,用于记录本轮开发推进状态。 -- [x] tipb: `Join.is_null_eq` 字段定义 -- [x] TiFlash: `JoinInterpreterHelper::TiFlashJoin` 解析 `is_null_eq[]` -- [x] TiFlash: `DB::Join` 保存/打印 `is_null_eq` -- [x] TiFlash: nullable NullEQ 强制 serialized + mixed-nullability key schema 对齐 + NullAware 互斥 fail-fast -- [x] TiFlash: build/probe 的 row_filter_map 语义拆分 -- [x] TiFlash: `RowsNotInsertToMap` / scan-after-probe 调整 -- [x] TiFlash: `FULL OUTER JOIN + other condition` 与 NullEQ 联动验证 -- [x] TiFlash: runtime filter 禁用 -- [x] TiFlash: gtest 已覆盖 inner / left / right / full / semi / anti 基础矩阵 -- [x] TiFlash: gtest 已覆盖 mixed key 与 side-condition 交互 -- [x] TiFlash: spill / fine-grained shuffle 测试覆盖 -- [x] TiFlash: packed keys 优化(nullable fixed-size NullEQ key 可走 `nullable_keys128/256`,其余场景回退 `serialized`) +- Note: the following checklist was verified against the current workspace and records the + progress of this development iteration. +- [x] tipb: `Join.is_null_eq` field definition. +- [x] TiFlash: `JoinInterpreterHelper::TiFlashJoin` parses `is_null_eq[]`. +- [x] TiFlash: `DB::Join` stores and logs `is_null_eq`. +- [x] TiFlash: force nullable NullEQ keys to `serialized`, align mixed-nullability key + schemas, and fail fast for NullAware conflicts. +- [x] TiFlash: split the build/probe `row_filter_map` semantics. +- [x] TiFlash: adjust `RowsNotInsertToMap` and scan-after-probe. +- [x] TiFlash: validate `FULL OUTER JOIN + other condition` with NullEQ. +- [x] TiFlash: disable runtime filters. +- [x] TiFlash: gtest covers the basic inner/left/right/full/semi/anti matrix. +- [x] TiFlash: gtest covers mixed keys and side-condition interaction. +- [x] TiFlash: spill/fine-grained shuffle coverage. +- [x] TiFlash: packed-key optimization (nullable fixed-size NullEQ keys may use + `nullable_keys128/256`; other cases fall back to `serialized`). ### Open Questions -- TiDB / kvproto 何时同步 `is_null_eq[]` -- key 若未来允许表达式,`is_null_eq[i]` 如何稳定对齐 -- string + collation 的性能回退是否可接受 -- spill / FGS 场景下是否需要单独的 profile 或 debug 指标 -- NullAware join 是否永远与 NullEQ 互斥,还是未来要定义组合语义 +- When will TiDB/kvproto synchronize `is_null_eq[]`? +- If expressions are allowed as keys in the future, how will `is_null_eq[i]` remain aligned? +- Is the performance fallback for strings and collations acceptable? +- Are separate profile or debug metrics needed for spill/FGS cases? +- Will NullAware join always be mutually exclusive with NullEQ, or should combined semantics + be defined in the future? From 10c7c699488c924d3a3abc044add702d500fae26 Mon Sep 17 00:00:00 2001 From: xufei Date: Thu, 20 Aug 2026 17:36:32 +0800 Subject: [PATCH 05/11] format code Signed-off-by: xufei --- dbms/src/Interpreters/tests/gtest_join_null_eq.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp index f3b2bc1b8ce..438e31b03ce 100644 --- a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp +++ b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp @@ -19,10 +19,10 @@ #include #include #include +#include #include #include #include -#include #include #include From 1eddaf3c4bcec0c4ffa550078e8b21c357904bb8 Mon Sep 17 00:00:00 2001 From: xufei Date: Fri, 21 Aug 2026 10:03:31 +0800 Subject: [PATCH 06/11] Fix null-eq join test output schema --- .../Interpreters/tests/gtest_join_null_eq.cpp | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp index 438e31b03ce..f74c8df16f4 100644 --- a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp +++ b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp @@ -142,7 +142,32 @@ JoinPtr makeOuterJoinTestJoin( const JoinNonEqualConditions & non_equal_conditions = JoinNonEqualConditions{}, const String & flag_helper_name = "") { - auto nullable_value_type = makeNullable(std::make_shared()); + auto int_type = std::make_shared(); + auto nullable_key_type = makeNullable(key_type); + auto nullable_value_type = makeNullable(int_type); + DataTypePtr probe_key_type = key_type; + DataTypePtr probe_value_type = int_type; + DataTypePtr build_key_type = key_type; + DataTypePtr build_value_type = int_type; + switch (kind) + { + case ASTTableJoin::Kind::LeftOuter: + build_key_type = nullable_key_type; + build_value_type = nullable_value_type; + break; + case ASTTableJoin::Kind::RightOuter: + probe_key_type = nullable_key_type; + probe_value_type = nullable_value_type; + break; + case ASTTableJoin::Kind::Full: + probe_key_type = nullable_key_type; + probe_value_type = nullable_value_type; + build_key_type = nullable_key_type; + build_value_type = nullable_value_type; + break; + default: + break; + } SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); return std::make_shared( @@ -157,10 +182,10 @@ JoinPtr makeOuterJoinTestJoin( probe_spill_config, RestoreConfig{1, 0, 0}, NamesAndTypes{ - {outer_probe_key_name, key_type}, - {outer_probe_value_name, nullable_value_type}, - {outer_build_key_name, key_type}, - {outer_build_value_name, nullable_value_type}, + {outer_probe_key_name, probe_key_type}, + {outer_probe_value_name, probe_value_type}, + {outer_build_key_name, build_key_type}, + {outer_build_value_name, build_value_type}, }, RegisterOperatorSpillContext{}, nullptr, From 339719472dca6aae53428f0fc5b21ba84838c9b2 Mon Sep 17 00:00:00 2001 From: xufei Date: Thu, 27 Aug 2026 15:21:05 +0800 Subject: [PATCH 07/11] refine null eq join for mixed nullability Signed-off-by: xufei --- .../Coprocessor/JoinInterpreterHelper.cpp | 19 +- .../Flash/Coprocessor/JoinInterpreterHelper.h | 7 +- .../gtest_join_get_kind_and_build_index.cpp | 19 +- dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp | 2 +- .../Interpreters/tests/gtest_join_null_eq.cpp | 267 ++++++++++++++++++ docs/note/nulleq_join.md | 33 +-- 6 files changed, 302 insertions(+), 45 deletions(-) diff --git a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp index 719d9c1b18e..1b1a6f3d289 100644 --- a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp +++ b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp @@ -436,8 +436,8 @@ std::tuple prepareJoin( return {chain.getLastActions(), std::move(key_names), std::move(original_key_names), std::move(filter_column_name)}; } -void alignNullEqKeyTypes( - const std::vector & is_null_eq, +void simplifyNullEqKeyFlags( + std::vector & is_null_eq, const ExpressionActionsPtr & probe_prepare_actions, Names & probe_key_names, const ExpressionActionsPtr & build_prepare_actions, @@ -453,23 +453,14 @@ void alignNullEqKeyTypes( const auto & probe_type = probe_prepare_actions->getSampleBlock().getByName(probe_key_names[i]).type; const auto & build_type = build_prepare_actions->getSampleBlock().getByName(build_key_names[i]).type; - if (probe_type->equals(*build_type)) - continue; - RUNTIME_CHECK_MSG( removeNullable(probe_type)->equals(*removeNullable(build_type)), - "NullEQ key type mismatch after prepareJoin is not a pure nullability mismatch: probe={} build={}", + "NullEQ key type mismatch after prepareJoin: probe={} build={}", probe_type->getName(), build_type->getName()); - if (!probe_type->isNullable()) - { - probe_prepare_actions->add(ExpressionAction::convertToNullable(probe_key_names[i])); - } - if (!build_type->isNullable()) - { - build_prepare_actions->add(ExpressionAction::convertToNullable(build_key_names[i])); - } + if (!probe_type->isNullable() || !build_type->isNullable()) + is_null_eq[i] = 0; } } diff --git a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h index cf690c8387c..de8899b5ceb 100644 --- a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h +++ b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h @@ -234,8 +234,11 @@ std::tuple prepareJoin( const JoinKeyTypes & join_key_types, const google::protobuf::RepeatedPtrField & filters); -void alignNullEqKeyTypes( - const std::vector & is_null_eq, +/// A NullEQ key is equivalent to an ordinary equality key when either side cannot be NULL. +/// Simplify such keys before constructing the join so the regular equality-key path can +/// strip nullable wrappers and filter NULL rows. +void simplifyNullEqKeyFlags( + std::vector & is_null_eq, const ExpressionActionsPtr & probe_prepare_actions, Names & probe_key_names, const ExpressionActionsPtr & build_prepare_actions, diff --git a/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp b/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp index dd06c87033b..7f0da13ae19 100644 --- a/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp +++ b/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp @@ -127,7 +127,7 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNullAwareJoinRejectsNullEqKeys) ASSERT_NE(error_message.find("NullEQ"), String::npos); } -TEST(JoinKindAndBuildIndexTestRunner, TestNullEqAlignsMixedNullabilityKeySchema) +TEST(JoinKindAndBuildIndexTestRunner, TestNullEqSimplifiesMixedNullabilityKeySchema) { try { @@ -168,18 +168,17 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNullEqAlignsMixedNullabilityKeySchema) ASSERT_FALSE(probe_prepare_actions->getSampleBlock().getByName(probe_key_names[0]).type->isNullable()); ASSERT_TRUE(build_prepare_actions->getSampleBlock().getByName(build_key_names[0]).type->isNullable()); - JoinInterpreterHelper::alignNullEqKeyTypes( + JoinInterpreterHelper::simplifyNullEqKeyFlags( tiflash_join.is_null_eq, probe_prepare_actions, probe_key_names, build_prepare_actions, build_key_names); - ASSERT_TRUE(probe_prepare_actions->getSampleBlock().getByName(probe_key_names[0]).type->isNullable()); + ASSERT_EQ(tiflash_join.is_null_eq, std::vector({0})); + ASSERT_FALSE(probe_prepare_actions->getSampleBlock().getByName(probe_key_names[0]).type->isNullable()); ASSERT_TRUE(build_prepare_actions->getSampleBlock().getByName(build_key_names[0]).type->isNullable()); - ASSERT_TRUE(probe_prepare_actions->getSampleBlock() - .getByName(probe_key_names[0]) - .type->equals(*build_prepare_actions->getSampleBlock().getByName(build_key_names[0]).type)); + ASSERT_FALSE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); } catch (Exception & e) { @@ -195,7 +194,7 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNullableNullEqDisablesRuntimeFilter) auto nullable_int_type = makeNullable(int_type); auto context = TiFlashTestEnv::getContext(); - ColumnWithTypeAndName probe_column{nullptr, int_type, "probe_k"}; + ColumnWithTypeAndName probe_column{nullptr, nullable_int_type, "probe_k"}; ColumnWithTypeAndName build_column{nullptr, nullable_int_type, "build_k"}; tipb::Join join; @@ -225,13 +224,14 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNullableNullEqDisablesRuntimeFilter) tiflash_join.join_key_types, tiflash_join.getBuildConditions()); - JoinInterpreterHelper::alignNullEqKeyTypes( + JoinInterpreterHelper::simplifyNullEqKeyFlags( tiflash_join.is_null_eq, probe_prepare_actions, probe_key_names, build_prepare_actions, build_key_names); + ASSERT_EQ(tiflash_join.is_null_eq, std::vector({1})); ASSERT_TRUE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); } catch (Exception & e) @@ -277,13 +277,14 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNonNullableNullEqKeepsRuntimeFilterEna tiflash_join.join_key_types, tiflash_join.getBuildConditions()); - JoinInterpreterHelper::alignNullEqKeyTypes( + JoinInterpreterHelper::simplifyNullEqKeyFlags( tiflash_join.is_null_eq, probe_prepare_actions, probe_key_names, build_prepare_actions, build_key_names); + ASSERT_EQ(tiflash_join.is_null_eq, std::vector({0})); ASSERT_FALSE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); } catch (Exception & e) diff --git a/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp b/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp index 9378a79fe22..86c68b3ba16 100644 --- a/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp +++ b/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp @@ -123,7 +123,7 @@ PhysicalPlanNodePtr PhysicalJoin::build( original_build_key_names, join_non_equal_conditions); - JoinInterpreterHelper::alignNullEqKeyTypes( + JoinInterpreterHelper::simplifyNullEqKeyFlags( tiflash_join.is_null_eq, probe_side_prepare_actions, probe_key_names, diff --git a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp index f74c8df16f4..b6ad537516e 100644 --- a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp +++ b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp @@ -444,6 +444,25 @@ ColumnPtr makeInt32Column(std::initializer_list values) return column; } +ColumnPtr makeInt64Column(std::initializer_list values) +{ + auto column = ColumnInt64::create(); + column->reserve(values.size()); + auto & data = column->getData(); + for (auto value : values) + data.push_back(value); + return column; +} + +ColumnPtr makeStringColumn(std::initializer_list values) +{ + auto column = ColumnString::create(); + column->reserve(values.size()); + for (const auto & value : values) + column->insertData(value.data(), value.size()); + return column; +} + Block readAllBlocks(const BlockInputStreamPtr & stream) { stream->readPrefix(); @@ -618,6 +637,254 @@ TEST(JoinNullEqTest, NullableStringNullEqFallsBackToSerializedJoinMapMethod) EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 1), 200); } +TEST(JoinNullEqTest, NullableToNonNullableNullEqSimplifiedToEqUsesSerializedJoinMapMethod) +{ + auto string_type = std::make_shared(); + auto nullable_string_type = makeNullable(string_type); + auto int_type = std::make_shared(); + SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); + SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); + auto join = std::make_shared( + Names{outer_probe_key_name, mixed_probe_key2_name}, + Names{outer_build_key_name, mixed_build_key2_name}, + // A Nullable(T) <=> T key is equivalent to T = T, so the effective flag is false. + std::vector{0, 0}, + ASTTableJoin::Kind::Inner, + "join_null_eq_mixed_nullability_test", + 0, + 0, + build_spill_config, + probe_spill_config, + RestoreConfig{1, 0, 0}, + NamesAndTypes{ + {outer_probe_key_name, nullable_string_type}, + {mixed_probe_key2_name, int_type}, + {outer_probe_value_name, int_type}, + {outer_build_key_name, string_type}, + {mixed_build_key2_name, int_type}, + {outer_build_value_name, int_type}, + }, + RegisterOperatorSpillContext{}, + nullptr, + TiDB::TiDBCollators{}, + JoinNonEqualConditions{}, + 1024, + 0, + "", + "", + 0, + true); + + join->initBuild( + Block{ + {string_type->createColumn(), string_type, outer_build_key_name}, + {int_type->createColumn(), int_type, mixed_build_key2_name}, + {int_type->createColumn(), int_type, outer_build_value_name}, + }, + 1); + join->initProbe( + Block{ + {nullable_string_type->createColumn(), nullable_string_type, outer_probe_key_name}, + {int_type->createColumn(), int_type, mixed_probe_key2_name}, + {int_type->createColumn(), int_type, outer_probe_value_name}, + }, + 1); + join->finalize(Names{ + outer_probe_key_name, + mixed_probe_key2_name, + outer_probe_value_name, + outer_build_key_name, + mixed_build_key2_name, + outer_build_value_name, + }); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::serialized); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeStringColumn({"alpha"}), string_type, outer_build_key_name}, + {makeInt32Column({7}), int_type, mixed_build_key2_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableStringColumn({std::nullopt, "alpha", "beta"}), nullable_string_type, outer_probe_key_name}, + {makeInt32Column({7, 7, 8}), int_type, mixed_probe_key2_name}, + {makeInt32Column({10, 20, 30}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_TRUE(probe_result.getByName(outer_probe_key_name).type->isNullable()); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 20); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, NullableToNonNullableNullEqSimplifiedToEqUsesStringJoinMapMethod) +{ + auto string_type = std::make_shared(); + auto nullable_string_type = makeNullable(string_type); + auto int_type = std::make_shared(); + SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); + SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); + auto join = std::make_shared( + Names{outer_probe_key_name}, + Names{outer_build_key_name}, + // A Nullable(T) <=> T key is equivalent to T = T, so the effective flag is false. + std::vector{0}, + ASTTableJoin::Kind::Inner, + "join_null_eq_mixed_nullability_test", + 0, + 0, + build_spill_config, + probe_spill_config, + RestoreConfig{1, 0, 0}, + NamesAndTypes{ + {outer_probe_key_name, nullable_string_type}, + {outer_probe_value_name, int_type}, + {outer_build_key_name, string_type}, + {outer_build_value_name, int_type}, + }, + RegisterOperatorSpillContext{}, + nullptr, + TiDB::TiDBCollators{}, + JoinNonEqualConditions{}, + 1024, + 0, + "", + "", + 0, + true); + + join->initBuild( + Block{ + {string_type->createColumn(), string_type, outer_build_key_name}, + {int_type->createColumn(), int_type, outer_build_value_name}, + }, + 1); + join->initProbe( + Block{ + {nullable_string_type->createColumn(), nullable_string_type, outer_probe_key_name}, + {int_type->createColumn(), int_type, outer_probe_value_name}, + }, + 1); + join->finalize(Names{ + outer_probe_key_name, + outer_probe_value_name, + outer_build_key_name, + outer_build_value_name, + }); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::key_strbin); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeStringColumn({"alpha"}), string_type, outer_build_key_name}, + {makeInt32Column({100}), int_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableStringColumn({std::nullopt, "alpha", "beta"}), nullable_string_type, outer_probe_key_name}, + {makeInt32Column({10, 20, 30}), int_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_TRUE(probe_result.getByName(outer_probe_key_name).type->isNullable()); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 20); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); +} + +TEST(JoinNullEqTest, NullableToNonNullableInt64NullEqSimplifiedToEqUsesKey64JoinMapMethod) +{ + auto int64_type = std::make_shared(); + auto nullable_int64_type = makeNullable(int64_type); + auto int32_type = std::make_shared(); + SpillConfig build_spill_config("/tmp", "join_null_eq_build", 0, 0, 0, nullptr); + SpillConfig probe_spill_config("/tmp", "join_null_eq_probe", 0, 0, 0, nullptr); + auto join = std::make_shared( + Names{outer_probe_key_name}, + Names{outer_build_key_name}, + // A Nullable(T) <=> T key is equivalent to T = T, so the effective flag is false. + std::vector{0}, + ASTTableJoin::Kind::Inner, + "join_null_eq_mixed_nullability_int64_test", + 0, + 0, + build_spill_config, + probe_spill_config, + RestoreConfig{1, 0, 0}, + NamesAndTypes{ + {outer_probe_key_name, nullable_int64_type}, + {outer_probe_value_name, int32_type}, + {outer_build_key_name, int64_type}, + {outer_build_value_name, int32_type}, + }, + RegisterOperatorSpillContext{}, + nullptr, + TiDB::TiDBCollators{}, + JoinNonEqualConditions{}, + 1024, + 0, + "", + "", + 0, + true); + + join->initBuild( + Block{ + {int64_type->createColumn(), int64_type, outer_build_key_name}, + {int32_type->createColumn(), int32_type, outer_build_value_name}, + }, + 1); + join->initProbe( + Block{ + {nullable_int64_type->createColumn(), nullable_int64_type, outer_probe_key_name}, + {int32_type->createColumn(), int32_type, outer_probe_value_name}, + }, + 1); + join->finalize(Names{ + outer_probe_key_name, + outer_probe_value_name, + outer_build_key_name, + outer_build_value_name, + }); + + ASSERT_EQ(join->getJoinMapMethod(), JoinMapMethod::key64); + + join->setInitActiveBuildThreads(); + join->insertFromBlock( + Block{ + {makeInt64Column({42}), int64_type, outer_build_key_name}, + {makeInt32Column({100}), int32_type, outer_build_value_name}, + }, + 0); + ASSERT_TRUE(join->finishOneBuild(0)); + join->finalizeBuild(); + + ProbeProcessInfo probe_process_info(1024, 0); + probe_process_info.resetBlock(Block{ + {makeNullableInt64Column({std::nullopt, 42, 43}), nullable_int64_type, outer_probe_key_name}, + {makeInt32Column({10, 20, 30}), int32_type, outer_probe_value_name}, + }); + Block probe_result = join->joinBlock(probe_process_info); + + ASSERT_EQ(probe_result.rows(), 1); + EXPECT_TRUE(probe_result.getByName(outer_probe_key_name).type->isNullable()); + EXPECT_EQ(getInt32Value(probe_result, outer_probe_value_name, 0), 20); + EXPECT_EQ(getInt32Value(probe_result, outer_build_value_name, 0), 100); +} + TEST(JoinNullEqTest, OversizedNullableFixedKeysFallBackToSerializedJoinMapMethod) { constexpr size_t fixed_string_size = 16; diff --git a/docs/note/nulleq_join.md b/docs/note/nulleq_join.md index 32100c22cae..bb0bec41b15 100644 --- a/docs/note/nulleq_join.md +++ b/docs/note/nulleq_join.md @@ -398,20 +398,15 @@ value. Therefore, for the same non-NULL value: are different. -This means that if one side of a NullEQ key pair is nullable and the other is non-nullable, -both sides may use `serialized` and still fail to match, as long as their final key schemas -remain `Nullable(T)` and `T`. +This means a mixed `Nullable(T)` / `T` pair cannot be sent directly through the NullEQ +serialized-key path. However, it also cannot produce a `NULL <=> NULL` match: the `T` side +never contains `NULL`. Its NullEQ predicate is therefore equivalent to an ordinary equality +predicate and is simplified to `=` before join construction. -Therefore, the MVP cannot merely force nullable NullEQ keys to `serialized`. It must also -ensure that: - -- for each `is_null_eq[i] = true` key pair; -- whenever either side needs to preserve nullable semantics; -- both build and probe sides are aligned to the same physical key schema during key preparation; -- the most direct approach is to normalize both sides to `Nullable(common_type)`. - -This schema-alignment requirement was initially part of the `serialized` correctness fallback. -It remains necessary after the fixed-size packed-key optimization is introduced. +After this simplification, the ordinary equality-key path strips the nullable wrapper from the +nullable side and filters its `NULL` rows. Both sides then serialize the same `T` value format. +Only a NullEQ key pair whose final types are both `Nullable(common_type)` needs to preserve +nullable physical encoding. ## 6. JoinPartition / KeyGetter Semantics @@ -428,11 +423,11 @@ The semantics are: - The packed-key path includes nullness in the key. - Variable-length keys or fixed-size keys that exceed `UInt256` continue to use `serialized`. -Regardless of the selected path, the following must hold: +Regardless of the selected path, the following must hold for a remaining NullEQ key: - Key columns passed by build and probe preserve nullable information for NullEQ keys. - For every NullEQ key pair, the final key schemas used by build and probe are identical. -- In particular, mixed nullable/non-nullable cases must not remain as `Nullable(T)` versus `T`. +- Both sides must be `Nullable(common_type)`. ## 7. FULL + Other Condition Semantics @@ -575,7 +570,7 @@ Done criteria: Done criteria: - Nullable NullEQ keys build and probe correctly. -- Mixed nullable/non-nullable NullEQ key pairs align their key schemas and match correctly. +- Mixed nullable/non-nullable NullEQ key pairs are simplified to ordinary equality keys. - Outer join and scan-after-probe do not misclassify NullEQ `NULL` rows as unmatched. - `FULL OUTER JOIN + other condition` has correct combined semantics with NullEQ. - Runtime filters are disabled in this mode. @@ -604,7 +599,7 @@ Done criteria: - CP0: tipb field and TiFlash parsing. - CP1: `DB::Join` stores and logs `is_null_eq`. -- CP2.1: force nullable NullEQ keys to `serialized`, align mixed-nullability key schemas, +- CP2.1: preserve nullable NullEQ keys, simplify mixed-nullability pairs to ordinary equality, and add the NullAware mutual-exclusion check. - CP2.2: split the build/probe `row_filter_map` semantics. - CP2.3: adjust `RowsNotInsertToMap` and scan-after-probe. @@ -620,8 +615,8 @@ Done criteria: - [x] tipb: `Join.is_null_eq` field definition. - [x] TiFlash: `JoinInterpreterHelper::TiFlashJoin` parses `is_null_eq[]`. - [x] TiFlash: `DB::Join` stores and logs `is_null_eq`. -- [x] TiFlash: force nullable NullEQ keys to `serialized`, align mixed-nullability key - schemas, and fail fast for NullAware conflicts. +- [x] TiFlash: preserve nullable NullEQ keys, simplify mixed-nullability pairs to ordinary + equality, and fail fast for NullAware conflicts. - [x] TiFlash: split the build/probe `row_filter_map` semantics. - [x] TiFlash: adjust `RowsNotInsertToMap` and scan-after-probe. - [x] TiFlash: validate `FULL OUTER JOIN + other condition` with NullEQ. From 23c4cd6a3900b74e3ffa56789ba7a7aa301a1978 Mon Sep 17 00:00:00 2001 From: xufei Date: Thu, 27 Aug 2026 16:22:02 +0800 Subject: [PATCH 08/11] refine null eq key type validation Signed-off-by: xufei --- dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp index 1b1a6f3d289..78418a686e9 100644 --- a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp +++ b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp @@ -453,14 +453,17 @@ void simplifyNullEqKeyFlags( const auto & probe_type = probe_prepare_actions->getSampleBlock().getByName(probe_key_names[i]).type; const auto & build_type = build_prepare_actions->getSampleBlock().getByName(build_key_names[i]).type; + if (!probe_type->isNullable() || !build_type->isNullable()) + { + is_null_eq[i] = 0; + continue; + } + RUNTIME_CHECK_MSG( - removeNullable(probe_type)->equals(*removeNullable(build_type)), + probe_type->equals(*build_type), "NullEQ key type mismatch after prepareJoin: probe={} build={}", probe_type->getName(), build_type->getName()); - - if (!probe_type->isNullable() || !build_type->isNullable()) - is_null_eq[i] = 0; } } From a470446ec6e771ec601cd28c62a200d70cc56ebf Mon Sep 17 00:00:00 2001 From: xufei Date: Thu, 27 Aug 2026 17:44:32 +0800 Subject: [PATCH 09/11] simplify null eq runtime filter check Signed-off-by: xufei --- .../Flash/Coprocessor/JoinInterpreterHelper.cpp | 15 +++------------ .../src/Flash/Coprocessor/JoinInterpreterHelper.h | 4 ++-- .../tests/gtest_join_get_kind_and_build_index.cpp | 6 +++--- dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp | 2 +- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp index 78418a686e9..1afad18d629 100644 --- a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp +++ b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.cpp @@ -507,20 +507,11 @@ std::vector TiFlashJoin::genRuntimeFilterList( return result; } -bool TiFlashJoin::shouldDisableRuntimeFilter( - const ExpressionActionsPtr & build_prepare_actions, - const Names & build_key_names) const +bool TiFlashJoin::shouldDisableRuntimeFilter() const { - RUNTIME_CHECK(build_prepare_actions != nullptr); - RUNTIME_CHECK(build_key_names.size() == is_null_eq.size()); - - const auto & sample_block = build_prepare_actions->getSampleBlock(); - for (size_t i = 0; i < is_null_eq.size(); ++i) + for (const auto flag : is_null_eq) { - if (is_null_eq[i] == 0) - continue; - - if (sample_block.getByName(build_key_names[i]).type->isNullable()) + if (flag != 0) return true; } return false; diff --git a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h index de8899b5ceb..aaf4d289343 100644 --- a/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h +++ b/dbms/src/Flash/Coprocessor/JoinInterpreterHelper.h @@ -219,8 +219,8 @@ struct TiFlashJoin const std::unordered_map & key_names_map, const LoggerPtr & log); - bool shouldDisableRuntimeFilter(const ExpressionActionsPtr & build_prepare_actions, const Names & build_key_names) - const; + /// Must be called after simplifyNullEqKeyFlags. A remaining NullEQ key has nullable keys on both sides. + bool shouldDisableRuntimeFilter() const; }; /// @join_prepare_expr_actions: generates join key columns and join filter column diff --git a/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp b/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp index 7f0da13ae19..2306d603617 100644 --- a/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp +++ b/dbms/src/Flash/Coprocessor/tests/gtest_join_get_kind_and_build_index.cpp @@ -178,7 +178,7 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNullEqSimplifiesMixedNullabilityKeySch ASSERT_EQ(tiflash_join.is_null_eq, std::vector({0})); ASSERT_FALSE(probe_prepare_actions->getSampleBlock().getByName(probe_key_names[0]).type->isNullable()); ASSERT_TRUE(build_prepare_actions->getSampleBlock().getByName(build_key_names[0]).type->isNullable()); - ASSERT_FALSE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); + ASSERT_FALSE(tiflash_join.shouldDisableRuntimeFilter()); } catch (Exception & e) { @@ -232,7 +232,7 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNullableNullEqDisablesRuntimeFilter) build_key_names); ASSERT_EQ(tiflash_join.is_null_eq, std::vector({1})); - ASSERT_TRUE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); + ASSERT_TRUE(tiflash_join.shouldDisableRuntimeFilter()); } catch (Exception & e) { @@ -285,7 +285,7 @@ TEST(JoinKindAndBuildIndexTestRunner, TestNonNullableNullEqKeepsRuntimeFilterEna build_key_names); ASSERT_EQ(tiflash_join.is_null_eq, std::vector({0})); - ASSERT_FALSE(tiflash_join.shouldDisableRuntimeFilter(build_prepare_actions, build_key_names)); + ASSERT_FALSE(tiflash_join.shouldDisableRuntimeFilter()); } catch (Exception & e) { diff --git a/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp b/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp index 86c68b3ba16..f7b3d955473 100644 --- a/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp +++ b/dbms/src/Flash/Planner/Plans/PhysicalJoin.cpp @@ -211,7 +211,7 @@ PhysicalPlanNodePtr PhysicalJoin::build( }; bool enable_runtime_filter = is_join_key_field_type_compatible(); - if (tiflash_join.shouldDisableRuntimeFilter(build_side_prepare_actions, build_key_names)) + if (tiflash_join.shouldDisableRuntimeFilter()) { LOG_INFO(log, "Disable runtime filter because a nullable NullEQ build key is present"); enable_runtime_filter = false; From 48ce86244b347002c99c5189b41aefd9d65dd91f Mon Sep 17 00:00:00 2001 From: xufei Date: Tue, 1 Sep 2026 13:11:08 +0800 Subject: [PATCH 10/11] Enforce NullEQ join key invariants --- dbms/src/Interpreters/Join.cpp | 31 ++++++++++++++----- dbms/src/Interpreters/Join.h | 1 + dbms/src/Interpreters/JoinHashMap.cpp | 5 +-- dbms/src/Interpreters/JoinHashMap.h | 2 +- .../Interpreters/tests/gtest_join_null_eq.cpp | 24 ++++++++++++-- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/dbms/src/Interpreters/Join.cpp b/dbms/src/Interpreters/Join.cpp index deb63b6afa8..d199dc89a79 100644 --- a/dbms/src/Interpreters/Join.cpp +++ b/dbms/src/Interpreters/Join.cpp @@ -56,10 +56,10 @@ extern const int TYPE_MISMATCH; namespace { -ColumnRawPtrs getKeyColumns(const Names & key_names, const Block & block, const std::vector & is_null_eq = {}) +ColumnRawPtrs getKeyColumns(const Names & key_names, const Block & block, const std::vector & is_null_eq) { size_t keys_size = key_names.size(); - RUNTIME_CHECK(is_null_eq.empty() || is_null_eq.size() == keys_size); + RUNTIME_CHECK(key_names.size() == is_null_eq.size()); ColumnRawPtrs key_columns(keys_size); for (size_t i = 0; i < keys_size; ++i) @@ -68,22 +68,25 @@ ColumnRawPtrs getKeyColumns(const Names & key_names, const Block & block, const /// Ordinary '=' keys join only nested values where all components are not NULL. /// NullEQ keys must keep their nullable wrapper so nullness can participate in key comparison. - if (key_columns[i]->isColumnNullable() && (is_null_eq.empty() || is_null_eq[i] == 0)) + if (key_columns[i]->isColumnNullable() && is_null_eq[i] == 0) key_columns[i] = &static_cast(*key_columns[i]).getNestedColumn(); } return key_columns; } -bool hasNullableNullEqKey(const Names & key_names, const Block & block, const std::vector & is_null_eq) +void checkNullEqKeyColumns(const Names & key_names, const Block & block, const std::vector & is_null_eq) { RUNTIME_CHECK(key_names.size() == is_null_eq.size()); for (size_t i = 0; i < key_names.size(); ++i) { - if (is_null_eq[i] != 0 && block.getByName(key_names[i]).type->isNullable()) - return true; + const auto & key_type = block.getByName(key_names[i]).type; + RUNTIME_CHECK_MSG( + is_null_eq[i] == 0 || key_type->isNullable(), + "NullEQ key {} must be Nullable, but its type is {}", + key_names[i], + key_type->getName()); } - return false; } size_t getRestoreJoinBuildConcurrency( @@ -212,6 +215,16 @@ Join::Join( , enable_fine_grained_shuffle(fine_grained_shuffle_count_ > 0) , fine_grained_shuffle_count(fine_grained_shuffle_count_) { + RUNTIME_CHECK_MSG( + key_names_left_.size() == key_names_right_.size(), + "Left and right join key sizes must be equal, left={}, right={}", + key_names_left_.size(), + key_names_right_.size()); + RUNTIME_CHECK_MSG( + key_names_left_.size() == is_null_eq_.size(), + "Join key size and is_null_eq size must be equal, keys={}, is_null_eq={}", + key_names_left_.size(), + is_null_eq_.size()); has_other_condition = non_equal_conditions.other_cond_expr != nullptr; bool is_semi = isSemiFamily(kind) || isLeftOuterSemiFamily(kind) || isNullAwareSemiFamily(kind); if (is_semi && !has_other_condition) @@ -443,13 +456,14 @@ void Join::initBuild(const Block & sample_block, size_t build_concurrency_) std::unique_lock lock(rwlock); if (unlikely(initialized)) throw Exception("Logical error: Join has been initialized", ErrorCodes::LOGICAL_ERROR); + checkNullEqKeyColumns(key_names_right, sample_block, is_null_eq); initialized = true; join_map_method = chooseJoinMapMethod( getKeyColumns(key_names_right, sample_block, is_null_eq), key_sizes, collators, is_null_eq); - if (hasNullableNullEqKey(key_names_right, sample_block, is_null_eq)) + if (hasNullEqKey(is_null_eq)) { if (join_map_method == JoinMapMethod::serialized) LOG_DEBUG(log, "Use serialized join map method because nullable NullEQ keys do not fit packed fixed keys"); @@ -486,6 +500,7 @@ void Join::initBuild(const Block & sample_block, size_t build_concurrency_) void Join::initProbe(const Block & sample_block, size_t probe_concurrency_) { std::unique_lock lock(rwlock); + checkNullEqKeyColumns(key_names_left, sample_block, is_null_eq); setProbeConcurrency(probe_concurrency_); probe_sample_block = sample_block; if (hash_join_spill_context->isSpillEnabled()) diff --git a/dbms/src/Interpreters/Join.h b/dbms/src/Interpreters/Join.h index dc85cbcbf9f..d2e7cfc7c28 100644 --- a/dbms/src/Interpreters/Join.h +++ b/dbms/src/Interpreters/Join.h @@ -159,6 +159,7 @@ using OneTimeNotifyFuturePtr = std::shared_ptr; class Join { public: + /// is_null_eq has one flag per join key pair. A nonzero flag requires Nullable key columns on both sides. Join( const Names & key_names_left_, const Names & key_names_right_, diff --git a/dbms/src/Interpreters/JoinHashMap.cpp b/dbms/src/Interpreters/JoinHashMap.cpp index 9faffffa11a..3c979194e43 100644 --- a/dbms/src/Interpreters/JoinHashMap.cpp +++ b/dbms/src/Interpreters/JoinHashMap.cpp @@ -41,7 +41,7 @@ JoinMapMethod chooseJoinMapMethod( const std::vector & is_null_eq) { const size_t keys_size = key_columns.size(); - RUNTIME_CHECK(is_null_eq.empty() || is_null_eq.size() == keys_size); + RUNTIME_CHECK(is_null_eq.size() == keys_size); if (keys_size == 0) return JoinMapMethod::CROSS; @@ -52,10 +52,11 @@ JoinMapMethod chooseJoinMapMethod( for (size_t j = 0; j < keys_size; ++j) { const auto * key_column = key_columns[j]; + RUNTIME_CHECK(is_null_eq[j] == 0 || key_column->isColumnNullable()); if (const auto * nullable_column = typeid_cast(key_column)) { nested_key_columns.push_back(&nullable_column->getNestedColumn()); - has_nullable_null_eq_key = has_nullable_null_eq_key || (!is_null_eq.empty() && is_null_eq[j] != 0); + has_nullable_null_eq_key = has_nullable_null_eq_key || is_null_eq[j] != 0; } else { diff --git a/dbms/src/Interpreters/JoinHashMap.h b/dbms/src/Interpreters/JoinHashMap.h index b5f0a975766..9f55e43d196 100644 --- a/dbms/src/Interpreters/JoinHashMap.h +++ b/dbms/src/Interpreters/JoinHashMap.h @@ -272,5 +272,5 @@ JoinMapMethod chooseJoinMapMethod( const ColumnRawPtrs & key_columns, Sizes & key_sizes, const TiDB::TiDBCollators & collators, - const std::vector & is_null_eq = {}); + const std::vector & is_null_eq); } // namespace DB diff --git a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp index b6ad537516e..fa492608557 100644 --- a/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp +++ b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp @@ -935,9 +935,27 @@ TEST(JoinNullEqTest, DefaultMethodSelectionRemainsForOtherCases) ASSERT_EQ(nullable_non_null_eq_join->getJoinMapMethod(), JoinMapMethod::key32); auto int_type = std::make_shared(); - auto non_nullable_null_eq_join = makeTestJoin(int_type, {1}); - non_nullable_null_eq_join->initBuild(makeSampleBlock(int_type), 1); - ASSERT_EQ(non_nullable_null_eq_join->getJoinMapMethod(), JoinMapMethod::key32); + auto non_nullable_eq_join = makeTestJoin(int_type, {0}); + non_nullable_eq_join->initBuild(makeSampleBlock(int_type), 1); + ASSERT_EQ(non_nullable_eq_join->getJoinMapMethod(), JoinMapMethod::key32); +} + +TEST(JoinNullEqTest, NullEqKeyMustBeNullable) +{ + auto int_type = std::make_shared(); + auto join = makeTestJoin(int_type, {1}); + ASSERT_THROW(join->initBuild(makeSampleBlock(int_type), 1), Exception); + + auto nullable_int_type = makeNullable(int_type); + join = makeTestJoin(nullable_int_type, {1}); + join->initBuild(makeSampleBlock(nullable_int_type), 1); + ASSERT_THROW(join->initProbe(makeSampleBlock(int_type), 1), Exception); +} + +TEST(JoinNullEqTest, NullEqFlagsMustMatchKeyCount) +{ + auto int_type = std::make_shared(); + ASSERT_THROW(makeTestJoin(int_type, {}), Exception); } TEST(JoinNullEqTest, NullableNullEqBuildRowsAreInsertedIntoHashMap) From 0a013bdd66c2b6b483ce75ba2ebce541e0c6846e Mon Sep 17 00:00:00 2001 From: xufei Date: Thu, 3 Sep 2026 19:57:27 +0800 Subject: [PATCH 11/11] disable nulleq join in hash join v2 Signed-off-by: xufei --- dbms/src/Flash/Planner/Plans/PhysicalJoinV2.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dbms/src/Flash/Planner/Plans/PhysicalJoinV2.cpp b/dbms/src/Flash/Planner/Plans/PhysicalJoinV2.cpp index cfb5ccab1e0..848e1c9afd8 100644 --- a/dbms/src/Flash/Planner/Plans/PhysicalJoinV2.cpp +++ b/dbms/src/Flash/Planner/Plans/PhysicalJoinV2.cpp @@ -205,6 +205,11 @@ const Block & PhysicalJoinV2::getSampleBlock() const bool PhysicalJoinV2::isSupported(const tipb::Join & join) { JoinInterpreterHelper::TiFlashJoin tiflash_join(join, false); + for (const auto is_nulleq : tiflash_join.is_null_eq) + { + if (is_nulleq) + return false; + } using enum ASTTableJoin::Kind; switch (tiflash_join.kind) {