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..1afad18d629 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,37 @@ std::tuple prepareJoin( return {chain.getLastActions(), std::move(key_names), std::move(original_key_names), std::move(filter_column_name)}; } +void simplifyNullEqKeyFlags( + 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->isNullable() || !build_type->isNullable()) + { + is_null_eq[i] = 0; + continue; + } + + RUNTIME_CHECK_MSG( + probe_type->equals(*build_type), + "NullEQ key type mismatch after prepareJoin: probe={} build={}", + probe_type->getName(), + build_type->getName()); + } +} + std::vector TiFlashJoin::genRuntimeFilterList( const Context & context, const NamesAndTypes & source_columns, @@ -452,6 +507,16 @@ std::vector TiFlashJoin::genRuntimeFilterList( return result; } +bool TiFlashJoin::shouldDisableRuntimeFilter() const +{ + for (const auto flag : is_null_eq) + { + if (flag != 0) + 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..aaf4d289343 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); + + /// 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 @@ -230,6 +234,16 @@ std::tuple prepareJoin( const JoinKeyTypes & join_key_types, const google::protobuf::RepeatedPtrField & filters); +/// 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, + 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..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 @@ -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,179 @@ 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, TestNullEqSimplifiesMixedNullabilityKeySchema) +{ + 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::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(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()); + } + 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, nullable_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::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()); + } + 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::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()); + } + 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..f7b3d955473 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::simplifyNullEqKeyFlags( + 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()) + { + 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/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) { 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/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..d199dc89a79 100644 --- a/dbms/src/Interpreters/Join.cpp +++ b/dbms/src/Interpreters/Join.cpp @@ -56,22 +56,39 @@ 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(key_names.size() == is_null_eq.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[i] == 0) key_columns[i] = &static_cast(*key_columns[i]).getNestedColumn(); } return key_columns; } + +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) + { + 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()); + } +} + size_t getRestoreJoinBuildConcurrency( size_t total_partitions, size_t spilled_partitions, @@ -101,6 +118,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 +158,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 +188,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) @@ -171,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) @@ -207,9 +261,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 +418,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 @@ -399,8 +456,18 @@ 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), key_sizes, collators); + join_map_method = chooseJoinMapMethod( + getKeyColumns(key_names_right, sample_block, is_null_eq), + key_sizes, + collators, + 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"); + } build_sample_block = sample_block; setBuildConcurrencyAndInitJoinPartition(build_concurrency_); hash_join_spill_context->init(build_concurrency); @@ -433,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()) @@ -680,13 +748,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 +788,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 +1302,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 +1376,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 +1416,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 +1646,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 +1739,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..d2e7cfc7c28 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): * @@ -160,9 +159,11 @@ 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_, + const std::vector & is_null_eq_, ASTTableJoin::Kind kind_, const String & req_id, size_t fine_grained_shuffle_count_, @@ -246,6 +247,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 +260,7 @@ class Join } const Names & getLeftJoinKeys() const { return key_names_left; } + const std::vector & getNullEqFlags() const { return is_null_eq; } void setInitActiveBuildThreads() { @@ -356,6 +359,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..3c979194e43 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,69 @@ 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.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]; + 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[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 +122,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 +150,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..9f55e43d196 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..fa492608557 --- /dev/null +++ b/dbms/src/Interpreters/tests/gtest_join_null_eq.cpp @@ -0,0 +1,1555 @@ +// 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 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( + 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, 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, + 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; +} + +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(); + 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, 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; + 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_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) +{ + 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..bb0bec41b15 --- /dev/null +++ b/docs/note/nulleq_join.md @@ -0,0 +1,637 @@ +# TiFlash NullEQ Join Key (`<=>` / `tidbNullEQ`) Design + +## Background + +The default equality semantics of TiFlash hash join are currently: + +- `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. + +This differs from the semantics required by null-safe equality (`<=>` / `tidbNullEQ`): + +- `NULL <=> NULL` is `true`. +- `NULL <=> non-NULL` is `false`. +- `non-NULL <=> non-NULL` has the same semantics as ordinary `=`. + +This document discusses how to add **join-key-level NullEQ semantics** to hash join, +assuming that TiFlash already supports `FULL OUTER JOIN`. + +## Goals + +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. `<=>` 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 + +The scope of this iteration is limited to: + +- Hash join. +- Non-empty `left_join_keys/right_join_keys`. +- NullEQ appearing only on join keys. + +The following cases are not included: + +- 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 Protocol + +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`: 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`: 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 Representation + +The MVP assumes that the planner sends join keys as column references: + +- `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 semantics are expressed only through `is_null_eq[]`: + +- 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. + +### Relationship with NullAware Join + +`is_null_aware_semi_join` and NullEQ represent different semantics: + +- NullAware join handles three-valued logic for `NOT IN`. +- NullEQ handles comparison semantics for join keys. + +The MVP recommends the following behavior: + +- If `is_null_aware_semi_join=true` and any `is_null_eq[i]=true`, fail fast. + +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 + +There are four main assumptions in the current join framework that directly conflict with +NullEQ. + +### 1. Key-NULL Rows Are Filtered Early + +The current build and probe paths process nullable keys in two steps: + +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()`. + +This means: + +- 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. + +That directly conflicts with the fact that `NULL <=> NULL` must be matchable. + +### 2. Side Conditions and Key-NULL Rows Share One null_map + +`recordFilteredRows()` currently reuses the same `null_map` to combine side-condition +results with the information about whether a key is `NULL`. + +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: + +- 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: + +- 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. + +### 3. RowsNotInsertToMap and Scan-After-Probe Treat NULL Keys as Naturally Unmatched + +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. + +This is valid under ordinary `=` semantics because rows with `NULL` keys do not match. + +Under NullEQ semantics: + +- 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. + +### 4. KeyGetter Does Not Encode the Nullable Bitmap by Default + +Fixed-key hash methods such as `keys128/keys256` currently default to +`has_nullable_keys = false`. + +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. + +## Additional Interaction with FULL OUTER JOIN + +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. + +### 1. The Natural-Unmatched NULL-Key Path Is Not Full-Join Specific, but FULL Amplifies It + +The impact differs by join type: + +- `LEFT OUTER JOIN` + - 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` + - 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` + - 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. + +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 Must Continue Using Delayed setUsed Semantics + +The current full-join path has a dedicated correction for `full + other condition`: + +- Do not call `setUsed()` immediately when the join key matches. +- Mark the build row as used only after the other condition actually passes. + +Otherwise, scan-after-probe may omit a build row that should be emitted as unmatched. + +After introducing NullEQ, this constraint still applies to `NULL <=> NULL` matches: + +- 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. + +Therefore, NullEQ must continue using the existing row-flagged and delayed-used design +for the full-join path. + +### 3. RowsNotInsertToMap Must Be Redefined for FULL + +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: + +- rows that failed a build-side condition; +- rows filtered because an ordinary `=` key contained `NULL`. + +It must not include: + +- rows whose NullEQ key is `NULL`. + +Those rows should enter the map and may match successfully. + +### 4. Dispatch Hash, Spill, and Fine-Grained Shuffle Expose More Errors Under FULL + +If dispatch hashing does not encode nullness into the key: + +- 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. + +Therefore, `full + NullEQ + spill/FGS` must be part of the MVP test matrix rather than +being deferred to a later iteration. + +### 5. FULL Schema Nullability Does Not Need a Separate NullEQ Extension + +No additional complexity is needed here: + +- 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 changes the **matching semantics**, not the nullable rules for the FULL output schema. + +## Design Choices + +## 1. General Principles + +The NullEQ design follows two core principles: + +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` + - 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` + - Meaningful only for ordinary `=` keys. + - A NullEQ key must not write `NULL` into this map. + +The names are retained to describe the source of each filtering result. + +In the final implementation, these concepts may be represented by one unified +`row_filter_map`: + +- `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. + +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". + +## 2. Distinguish `=` and `<=>` by Key + +For each 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. + +NullEQ does not make the entire join null-safe. It takes effect independently for each +key pair. + +## 3. Build-Path Design + +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. + +Recommended approach: + +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, ...)`. + +The build-path semantics then become: + +- `row_filter_map[i] = 1`: do not insert this row into the map. +- `row_filter_map[i] = 0`: insert this row into the map. + +For join kinds that record special build-side rows, such as full/right outer, right semi, +and right anti: + +- 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`. + +## 4. Probe-Path Design + +The probe path must satisfy the following: + +- 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. + +The recommended approach mirrors the build path: + +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. + +The effects on outer joins are: + +- `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 + +### Current Implementation + +When a nullable NullEQ key is present, the current Join map-method selection is: + +- 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`. + +The fixed-size path reuses the nullable packed-key approach already used by HashAgg/Set: + +- `keys128/keys256 + has_nullable_keys = true`; +- encode the nullness bitmap together with the key payload in the packed key. + +This means common nullable numeric/datetime NullEQ joins do not always need to fall back +to `serialized`. + +`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`. + +There is one prerequisite that must be satisfied explicitly: + +- `serialized` preserves nullness based on the current column object. +- It does not automatically normalize `Nullable(T)` and `T` to the same physical encoding. + +`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. + +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. + +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 + +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. + +The current JoinPartition has explicit nullable-aware fixed-key KeyGetter branches: + +- `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`. + +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. +- Both sides must be `Nullable(common_type)`. + +## 7. FULL + Other Condition Semantics + +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: + +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. + +Otherwise, FULL may omit a build row or emit duplicate unmatched rows. + +## 8. RuntimeFilter + +The MVP recommends: + +- 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. + +A possible long-term direction is: + +- maintain an additional `has_null` flag in Set; +- apply a single-column NullEQ runtime filter as: + - `isNull(x) ? has_null : (x IN set)`. + +This is not recommended for the MVP. + +## 9. Alternative: Rewrite `<=>` in the Planner + +Another approach is for the TiDB planner not to send explicit `is_null_eq[]`, but to rewrite +each `<=>` key into: + +1. `isNull(k)`; +2. `ifNull(k, sentinel)`. + +TiFlash could then continue using ordinary `=` joins. + +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. + +Therefore, this design chooses native key-level NullEQ support in TiFlash. + +## Testing Recommendations + +The MVP should cover at least: + +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 Test Progress + +Based on the current workspace progress, the CP3 spill/FGS paths are covered: + +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` +- `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` + +--- + +## Development Tracking / Dev Note + +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 + +Before continuing development, it is recommended to always do three things: + +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: + +- "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." + +### Milestones + +#### Milestone 0: Protocol / Plumbing + +Done criteria: + +- 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: Correctness MVP + +Done criteria: + +- Nullable NullEQ keys build and probe 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. + +#### Milestone 2: Test Matrix + +Done criteria: + +- The basic inner/left/right/full/semi/anti matrix is covered. +- Mixed keys, side conditions, and spill/FGS are covered. + +#### Milestone 3: Performance Optimization + +Done criteria: + +- Nullable fixed-size keys no longer always fall back to `serialized`. + +#### Milestone 4: RuntimeFilter (Optional) + +Done criteria: + +- Runtime-filter semantics for a single-column NullEQ key are correct, or long-term + disabling is explicitly documented. + +### Suggested Checkpoints + +- CP0: tipb field and TiFlash parsing. +- CP1: `DB::Join` stores and logs `is_null_eq`. +- 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. +- 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 + +- 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: 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. +- [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 + +- 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?