From d1a8b09809c5e2279d708aae7990ba2d2b5f4794 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Wed, 11 Feb 2026 20:51:51 +0800 Subject: [PATCH 001/272] Fix `SELECT *` returning right table columns for `SEMI` and `ANTI` JOIN --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 37 ++++++++++++++----- src/Core/Settings.cpp | 3 ++ src/Core/SettingsChangesHistory.cpp | 2 + ...03913_semi_anti_join_select_star.reference | 7 ++++ .../03913_semi_anti_join_select_star.sql | 36 ++++++++++++++++++ 5 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 tests/queries/0_stateless/03913_semi_anti_join_select_star.reference create mode 100644 tests/queries/0_stateless/03913_semi_anti_join_select_star.sql diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 58af72275cb9..01534d6665b8 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -67,6 +67,7 @@ namespace Setting extern const SettingsBool asterisk_include_materialized_columns; extern const SettingsString count_distinct_implementation; extern const SettingsBool enable_global_with_statement; + extern const SettingsBool select_star_skip_non_preserved_side_for_semi_anti_join; extern const SettingsBool enable_scopes_for_with_statement; extern const SettingsBool enable_order_by_all; extern const SettingsBool enable_positional_arguments; @@ -1965,20 +1966,38 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( } } - for (auto && left_table_column_with_name : left_table_expression_columns) - { - if (table_expression_column_names_to_skip.contains(left_table_column_with_name.second)) - continue; + /** For SEMI/ANTI JOIN, SELECT * should only return columns from one side per SQL standard: + * - LEFT SEMI/ANTI JOIN: only left table columns + * - RIGHT SEMI/ANTI JOIN: only right table columns + * Controlled by `select_star_skip_non_preserved_side_for_semi_anti_join` setting. + */ + bool is_semi_or_anti = join_node->getStrictness() == JoinStrictness::Semi + || join_node->getStrictness() == JoinStrictness::Anti; + bool skip_non_preserved_side = is_semi_or_anti + && scope.context->getSettingsRef()[Setting::select_star_skip_non_preserved_side_for_semi_anti_join]; + bool skip_left = skip_non_preserved_side && isRight(join_node->getKind()); + bool skip_right = skip_non_preserved_side && isLeft(join_node->getKind()); - matched_expression_nodes_with_column_names.push_back(std::move(left_table_column_with_name)); + if (!skip_left) + { + for (auto && left_table_column_with_name : left_table_expression_columns) + { + if (table_expression_column_names_to_skip.contains(left_table_column_with_name.second)) + continue; + + matched_expression_nodes_with_column_names.push_back(std::move(left_table_column_with_name)); + } } - for (auto && right_table_column_with_name : right_table_expression_columns) + if (!skip_right) { - if (table_expression_column_names_to_skip.contains(right_table_column_with_name.second)) - continue; + for (auto && right_table_column_with_name : right_table_expression_columns) + { + if (table_expression_column_names_to_skip.contains(right_table_column_with_name.second)) + continue; - matched_expression_nodes_with_column_names.push_back(std::move(right_table_column_with_name)); + matched_expression_nodes_with_column_names.push_back(std::move(right_table_column_with_name)); + } } table_expressions_column_nodes_with_names_stack.push_back(std::move(matched_expression_nodes_with_column_names)); diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index a1e4b8f4172e..8288447ba3e5 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7073,6 +7073,9 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE )", 0) \ DECLARE(Bool, analyzer_compatibility_allow_compound_identifiers_in_unflatten_nested, true, R"( Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. + )", 0) \ + DECLARE(Bool, select_star_skip_non_preserved_side_for_semi_anti_join, false, R"( +When enabled, `SELECT *` in `SEMI` or `ANTI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT SEMI/ANTI JOIN` only left table columns are returned, for `RIGHT SEMI/ANTI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. )", 0) \ \ DECLARE(Timezone, session_timezone, "", R"( diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 74ef09928c8c..de824fbf9759 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -80,6 +80,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"jemalloc_profile_text_symbolize_with_inline", true, true, "New setting to control whether to include inline frames when symbolizing jemalloc heap profile. When enabled, inline frames are included at the cost of slower symbolization; when disabled, they are skipped for faster output"}, {"jemalloc_profile_text_collapsed_use_count", false, false, "New setting to aggregate by allocation count instead of bytes in the collapsed jemalloc heap profile format"}, {"opentelemetry_start_keeper_trace_probability", "auto", "auto", "New setting"}, + {"opentelemetry_start_keeper_trace_probability", 0.0, 0.0, "New setting"}, + {"select_star_skip_non_preserved_side_for_semi_anti_join", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI and ANTI JOINs"}, }); addSettingsChanges(settings_changes_history, "26.1", { diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference b/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference new file mode 100644 index 000000000000..b349025b4157 --- /dev/null +++ b/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference @@ -0,0 +1,7 @@ +1 +1 +1 2 +2 +2 +1 0 +1 0 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql new file mode 100644 index 000000000000..07e02dbd6348 --- /dev/null +++ b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql @@ -0,0 +1,36 @@ +-- Test that SELECT * in SEMI/ANTI JOIN follows SQL standard semantics +-- when select_star_skip_non_preserved_side_for_semi_anti_join = 1: +-- only returns columns from the preserved side + +-- only with new analyzer +SET allow_experimental_analyzer = 1; + +SET select_star_skip_non_preserved_side_for_semi_anti_join = 1; + +-- LEFT ANTI JOIN with false condition: all left rows returned, only left columns +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; + +-- LEFT ANTI JOIN with true condition: no rows returned +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON true; + +-- LEFT SEMI JOIN with true condition: matched rows, only left columns +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- LEFT SEMI JOIN with false condition: no rows +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON false; + +-- Multiple columns on left side +SELECT * FROM (SELECT 1 AS a, 2 AS c) t1 LEFT ANTI JOIN (SELECT 3 AS b, 4 AS d) t2 ON false; + +-- RIGHT ANTI JOIN: only right columns +SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false; + +-- RIGHT SEMI JOIN: only right columns +SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- Explicit column reference still works (t1.*, t2.* are qualified matchers) +SELECT t1.*, t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; + +-- Default behavior (setting = 0): returns columns from both sides +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false +SETTINGS select_star_skip_non_preserved_side_for_semi_anti_join = 0; From 5a4f45e6751051057a705002d4c26c44c7708e1a Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Wed, 25 Feb 2026 18:02:08 +0800 Subject: [PATCH 002/272] replace old setting with two settings --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 16 ++++++----- src/Core/Settings.cpp | 7 +++-- src/Core/SettingsChangesHistory.cpp | 4 +-- .../0_stateless/02995_settings_26_2_1.tsv | 2 ++ ...03913_semi_anti_join_select_star.reference | 3 ++- .../03913_semi_anti_join_select_star.sql | 27 ++++++++++++------- 6 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 01534d6665b8..f5f14a3eb183 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -67,7 +67,8 @@ namespace Setting extern const SettingsBool asterisk_include_materialized_columns; extern const SettingsString count_distinct_implementation; extern const SettingsBool enable_global_with_statement; - extern const SettingsBool select_star_skip_non_preserved_side_for_semi_anti_join; + extern const SettingsBool select_star_skip_non_preserved_side_for_semi_join; + extern const SettingsBool select_star_skip_non_preserved_side_for_anti_join; extern const SettingsBool enable_scopes_for_with_statement; extern const SettingsBool enable_order_by_all; extern const SettingsBool enable_positional_arguments; @@ -1969,12 +1970,15 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( /** For SEMI/ANTI JOIN, SELECT * should only return columns from one side per SQL standard: * - LEFT SEMI/ANTI JOIN: only left table columns * - RIGHT SEMI/ANTI JOIN: only right table columns - * Controlled by `select_star_skip_non_preserved_side_for_semi_anti_join` setting. + * Controlled by `select_star_skip_non_preserved_side_for_semi_join` + * and `select_star_skip_non_preserved_side_for_anti_join` settings. */ - bool is_semi_or_anti = join_node->getStrictness() == JoinStrictness::Semi - || join_node->getStrictness() == JoinStrictness::Anti; - bool skip_non_preserved_side = is_semi_or_anti - && scope.context->getSettingsRef()[Setting::select_star_skip_non_preserved_side_for_semi_anti_join]; + bool is_semi = join_node->getStrictness() == JoinStrictness::Semi; + bool is_anti = join_node->getStrictness() == JoinStrictness::Anti; + const auto & settings = scope.context->getSettingsRef(); + bool skip_non_preserved_side = + (is_semi && settings[Setting::select_star_skip_non_preserved_side_for_semi_join]) + || (is_anti && settings[Setting::select_star_skip_non_preserved_side_for_anti_join]); bool skip_left = skip_non_preserved_side && isRight(join_node->getKind()); bool skip_right = skip_non_preserved_side && isLeft(join_node->getKind()); diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index 8288447ba3e5..3811c27bb1f7 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7074,8 +7074,11 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE DECLARE(Bool, analyzer_compatibility_allow_compound_identifiers_in_unflatten_nested, true, R"( Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. )", 0) \ - DECLARE(Bool, select_star_skip_non_preserved_side_for_semi_anti_join, false, R"( -When enabled, `SELECT *` in `SEMI` or `ANTI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT SEMI/ANTI JOIN` only left table columns are returned, for `RIGHT SEMI/ANTI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. + DECLARE(Bool, select_star_skip_non_preserved_side_for_semi_join, false, R"( +When enabled, `SELECT *` in `SEMI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT SEMI JOIN` only left table columns are returned, for `RIGHT SEMI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. + )", 0) \ + DECLARE(Bool, select_star_skip_non_preserved_side_for_anti_join, false, R"( +When enabled, `SELECT *` in `ANTI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT ANTI JOIN` only left table columns are returned, for `RIGHT ANTI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. )", 0) \ \ DECLARE(Timezone, session_timezone, "", R"( diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index de824fbf9759..73424023b49b 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -80,8 +80,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"jemalloc_profile_text_symbolize_with_inline", true, true, "New setting to control whether to include inline frames when symbolizing jemalloc heap profile. When enabled, inline frames are included at the cost of slower symbolization; when disabled, they are skipped for faster output"}, {"jemalloc_profile_text_collapsed_use_count", false, false, "New setting to aggregate by allocation count instead of bytes in the collapsed jemalloc heap profile format"}, {"opentelemetry_start_keeper_trace_probability", "auto", "auto", "New setting"}, - {"opentelemetry_start_keeper_trace_probability", 0.0, 0.0, "New setting"}, - {"select_star_skip_non_preserved_side_for_semi_anti_join", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI and ANTI JOINs"}, + {"select_star_skip_non_preserved_side_for_semi_join", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, + {"select_star_skip_non_preserved_side_for_anti_join", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, }); addSettingsChanges(settings_changes_history, "26.1", { diff --git a/tests/queries/0_stateless/02995_settings_26_2_1.tsv b/tests/queries/0_stateless/02995_settings_26_2_1.tsv index a4e46bdddce1..ac96ef8c0647 100644 --- a/tests/queries/0_stateless/02995_settings_26_2_1.tsv +++ b/tests/queries/0_stateless/02995_settings_26_2_1.tsv @@ -1380,6 +1380,8 @@ schema_inference_use_cache_for_s3 1 schema_inference_use_cache_for_url 1 secondary_indices_enable_bulk_filtering 1 select_sequential_consistency 0 +select_star_skip_non_preserved_side_for_anti_join 0 +select_star_skip_non_preserved_side_for_semi_join 0 send_logs_level fatal send_logs_source_regexp send_profile_events 1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference b/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference index b349025b4157..11e675b1b5e7 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference @@ -1,7 +1,8 @@ 1 -1 1 2 2 +1 2 +1 2 1 0 1 0 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql index 07e02dbd6348..700d7b102295 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql @@ -1,11 +1,12 @@ -- Test that SELECT * in SEMI/ANTI JOIN follows SQL standard semantics --- when select_star_skip_non_preserved_side_for_semi_anti_join = 1: +-- when the corresponding setting is enabled: -- only returns columns from the preserved side -- only with new analyzer SET allow_experimental_analyzer = 1; -SET select_star_skip_non_preserved_side_for_semi_anti_join = 1; +-- Test ANTI JOIN setting +SET select_star_skip_non_preserved_side_for_anti_join = 1; -- LEFT ANTI JOIN with false condition: all left rows returned, only left columns SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; @@ -13,24 +14,30 @@ SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; -- LEFT ANTI JOIN with true condition: no rows returned SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON true; --- LEFT SEMI JOIN with true condition: matched rows, only left columns -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; - --- LEFT SEMI JOIN with false condition: no rows -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON false; - -- Multiple columns on left side SELECT * FROM (SELECT 1 AS a, 2 AS c) t1 LEFT ANTI JOIN (SELECT 3 AS b, 4 AS d) t2 ON false; -- RIGHT ANTI JOIN: only right columns SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false; +-- SEMI JOIN is not affected by anti_join setting: returns columns from both sides +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- Test SEMI JOIN setting +SET select_star_skip_non_preserved_side_for_semi_join = 1; + +-- LEFT SEMI JOIN with true condition: matched rows, only left columns +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- LEFT SEMI JOIN with false condition: no rows +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON false; + -- RIGHT SEMI JOIN: only right columns SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- Explicit column reference still works (t1.*, t2.* are qualified matchers) SELECT t1.*, t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; --- Default behavior (setting = 0): returns columns from both sides +-- Default behavior (both settings = 0): returns columns from both sides SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false -SETTINGS select_star_skip_non_preserved_side_for_semi_anti_join = 0; +SETTINGS select_star_skip_non_preserved_side_for_anti_join = 0; From 5866f882ed7ce8e153f6f1c0476cc7c76e9b2c74 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Wed, 25 Feb 2026 18:29:45 +0800 Subject: [PATCH 003/272] modify settings name --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 12 ++++++------ src/Core/Settings.cpp | 4 ++-- src/Core/SettingsChangesHistory.cpp | 4 ++-- tests/queries/0_stateless/02995_settings_26_2_1.tsv | 4 ++-- .../0_stateless/03913_semi_anti_join_select_star.sql | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index f5f14a3eb183..21b0df1b7ceb 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -67,8 +67,8 @@ namespace Setting extern const SettingsBool asterisk_include_materialized_columns; extern const SettingsString count_distinct_implementation; extern const SettingsBool enable_global_with_statement; - extern const SettingsBool select_star_skip_non_preserved_side_for_semi_join; - extern const SettingsBool select_star_skip_non_preserved_side_for_anti_join; + extern const SettingsBool semi_join_select_star_compatibility; + extern const SettingsBool anti_join_select_star_compatibility; extern const SettingsBool enable_scopes_for_with_statement; extern const SettingsBool enable_order_by_all; extern const SettingsBool enable_positional_arguments; @@ -1970,15 +1970,15 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( /** For SEMI/ANTI JOIN, SELECT * should only return columns from one side per SQL standard: * - LEFT SEMI/ANTI JOIN: only left table columns * - RIGHT SEMI/ANTI JOIN: only right table columns - * Controlled by `select_star_skip_non_preserved_side_for_semi_join` - * and `select_star_skip_non_preserved_side_for_anti_join` settings. + * Controlled by `semi_join_select_star_compatibility` + * and `anti_join_select_star_compatibility` settings. */ bool is_semi = join_node->getStrictness() == JoinStrictness::Semi; bool is_anti = join_node->getStrictness() == JoinStrictness::Anti; const auto & settings = scope.context->getSettingsRef(); bool skip_non_preserved_side = - (is_semi && settings[Setting::select_star_skip_non_preserved_side_for_semi_join]) - || (is_anti && settings[Setting::select_star_skip_non_preserved_side_for_anti_join]); + (is_semi && settings[Setting::semi_join_select_star_compatibility]) + || (is_anti && settings[Setting::anti_join_select_star_compatibility]); bool skip_left = skip_non_preserved_side && isRight(join_node->getKind()); bool skip_right = skip_non_preserved_side && isLeft(join_node->getKind()); diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index 3811c27bb1f7..c36de55495dd 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7074,10 +7074,10 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE DECLARE(Bool, analyzer_compatibility_allow_compound_identifiers_in_unflatten_nested, true, R"( Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. )", 0) \ - DECLARE(Bool, select_star_skip_non_preserved_side_for_semi_join, false, R"( + DECLARE(Bool, semi_join_select_star_compatibility, false, R"( When enabled, `SELECT *` in `SEMI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT SEMI JOIN` only left table columns are returned, for `RIGHT SEMI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. )", 0) \ - DECLARE(Bool, select_star_skip_non_preserved_side_for_anti_join, false, R"( + DECLARE(Bool, anti_join_select_star_compatibility, false, R"( When enabled, `SELECT *` in `ANTI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT ANTI JOIN` only left table columns are returned, for `RIGHT ANTI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. )", 0) \ \ diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 73424023b49b..a0ed3c1e225c 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -80,8 +80,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"jemalloc_profile_text_symbolize_with_inline", true, true, "New setting to control whether to include inline frames when symbolizing jemalloc heap profile. When enabled, inline frames are included at the cost of slower symbolization; when disabled, they are skipped for faster output"}, {"jemalloc_profile_text_collapsed_use_count", false, false, "New setting to aggregate by allocation count instead of bytes in the collapsed jemalloc heap profile format"}, {"opentelemetry_start_keeper_trace_probability", "auto", "auto", "New setting"}, - {"select_star_skip_non_preserved_side_for_semi_join", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, - {"select_star_skip_non_preserved_side_for_anti_join", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, + {"semi_join_select_star_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, + {"anti_join_select_star_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, }); addSettingsChanges(settings_changes_history, "26.1", { diff --git a/tests/queries/0_stateless/02995_settings_26_2_1.tsv b/tests/queries/0_stateless/02995_settings_26_2_1.tsv index ac96ef8c0647..399e2d78ed42 100644 --- a/tests/queries/0_stateless/02995_settings_26_2_1.tsv +++ b/tests/queries/0_stateless/02995_settings_26_2_1.tsv @@ -1380,8 +1380,8 @@ schema_inference_use_cache_for_s3 1 schema_inference_use_cache_for_url 1 secondary_indices_enable_bulk_filtering 1 select_sequential_consistency 0 -select_star_skip_non_preserved_side_for_anti_join 0 -select_star_skip_non_preserved_side_for_semi_join 0 +anti_join_select_star_compatibility 0 +semi_join_select_star_compatibility 0 send_logs_level fatal send_logs_source_regexp send_profile_events 1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql index 700d7b102295..fcf65bf7737c 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql @@ -6,7 +6,7 @@ SET allow_experimental_analyzer = 1; -- Test ANTI JOIN setting -SET select_star_skip_non_preserved_side_for_anti_join = 1; +SET anti_join_select_star_compatibility = 1; -- LEFT ANTI JOIN with false condition: all left rows returned, only left columns SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; @@ -24,7 +24,7 @@ SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false; SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- Test SEMI JOIN setting -SET select_star_skip_non_preserved_side_for_semi_join = 1; +SET semi_join_select_star_compatibility = 1; -- LEFT SEMI JOIN with true condition: matched rows, only left columns SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; @@ -40,4 +40,4 @@ SELECT t1.*, t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON f -- Default behavior (both settings = 0): returns columns from both sides SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false -SETTINGS select_star_skip_non_preserved_side_for_anti_join = 0; +SETTINGS anti_join_select_star_compatibility = 0; From 3f0146ee542ac1743a3550e98fac492b1884eb1b Mon Sep 17 00:00:00 2001 From: Dmitry Novik Date: Thu, 26 Feb 2026 20:38:14 +0100 Subject: [PATCH 004/272] Remove select_start_ from setting names --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 12 ++++++------ src/Core/Settings.cpp | 8 ++++---- src/Core/SettingsChangesHistory.cpp | 4 ++-- tests/queries/0_stateless/02995_settings_26_2_1.tsv | 4 ++-- .../0_stateless/03913_semi_anti_join_select_star.sql | 6 +++--- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 21b0df1b7ceb..47bd6db9d568 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -67,8 +67,8 @@ namespace Setting extern const SettingsBool asterisk_include_materialized_columns; extern const SettingsString count_distinct_implementation; extern const SettingsBool enable_global_with_statement; - extern const SettingsBool semi_join_select_star_compatibility; - extern const SettingsBool anti_join_select_star_compatibility; + extern const SettingsBool semi_join_compatibility; + extern const SettingsBool anti_join_compatibility; extern const SettingsBool enable_scopes_for_with_statement; extern const SettingsBool enable_order_by_all; extern const SettingsBool enable_positional_arguments; @@ -1970,15 +1970,15 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( /** For SEMI/ANTI JOIN, SELECT * should only return columns from one side per SQL standard: * - LEFT SEMI/ANTI JOIN: only left table columns * - RIGHT SEMI/ANTI JOIN: only right table columns - * Controlled by `semi_join_select_star_compatibility` - * and `anti_join_select_star_compatibility` settings. + * Controlled by `semi_join_compatibility` + * and `anti_join_compatibility` settings. */ bool is_semi = join_node->getStrictness() == JoinStrictness::Semi; bool is_anti = join_node->getStrictness() == JoinStrictness::Anti; const auto & settings = scope.context->getSettingsRef(); bool skip_non_preserved_side = - (is_semi && settings[Setting::semi_join_select_star_compatibility]) - || (is_anti && settings[Setting::anti_join_select_star_compatibility]); + (is_semi && settings[Setting::semi_join_compatibility]) + || (is_anti && settings[Setting::anti_join_compatibility]); bool skip_left = skip_non_preserved_side && isRight(join_node->getKind()); bool skip_right = skip_non_preserved_side && isLeft(join_node->getKind()); diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index c36de55495dd..be4f99d7c3ed 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7074,11 +7074,11 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE DECLARE(Bool, analyzer_compatibility_allow_compound_identifiers_in_unflatten_nested, true, R"( Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. )", 0) \ - DECLARE(Bool, semi_join_select_star_compatibility, false, R"( -When enabled, `SELECT *` in `SEMI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT SEMI JOIN` only left table columns are returned, for `RIGHT SEMI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. + DECLARE(Bool, semi_join_compatibility, false, R"( +When enabled, `SEMI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT SEMI JOIN` only left table columns are returned, for `RIGHT SEMI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. )", 0) \ - DECLARE(Bool, anti_join_select_star_compatibility, false, R"( -When enabled, `SELECT *` in `ANTI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT ANTI JOIN` only left table columns are returned, for `RIGHT ANTI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. + DECLARE(Bool, anti_join_compatibility, false, R"( +When enabled, `ANTI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT ANTI JOIN` only left table columns are returned, for `RIGHT ANTI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. )", 0) \ \ DECLARE(Timezone, session_timezone, "", R"( diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index a0ed3c1e225c..97e0418fbd67 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -80,8 +80,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"jemalloc_profile_text_symbolize_with_inline", true, true, "New setting to control whether to include inline frames when symbolizing jemalloc heap profile. When enabled, inline frames are included at the cost of slower symbolization; when disabled, they are skipped for faster output"}, {"jemalloc_profile_text_collapsed_use_count", false, false, "New setting to aggregate by allocation count instead of bytes in the collapsed jemalloc heap profile format"}, {"opentelemetry_start_keeper_trace_probability", "auto", "auto", "New setting"}, - {"semi_join_select_star_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, - {"anti_join_select_star_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, + {"semi_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, + {"anti_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, }); addSettingsChanges(settings_changes_history, "26.1", { diff --git a/tests/queries/0_stateless/02995_settings_26_2_1.tsv b/tests/queries/0_stateless/02995_settings_26_2_1.tsv index 399e2d78ed42..463db2b967ef 100644 --- a/tests/queries/0_stateless/02995_settings_26_2_1.tsv +++ b/tests/queries/0_stateless/02995_settings_26_2_1.tsv @@ -1380,8 +1380,8 @@ schema_inference_use_cache_for_s3 1 schema_inference_use_cache_for_url 1 secondary_indices_enable_bulk_filtering 1 select_sequential_consistency 0 -anti_join_select_star_compatibility 0 -semi_join_select_star_compatibility 0 +anti_join_compatibility 0 +semi_join_compatibility 0 send_logs_level fatal send_logs_source_regexp send_profile_events 1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql index fcf65bf7737c..dd44fd3e4e06 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql @@ -6,7 +6,7 @@ SET allow_experimental_analyzer = 1; -- Test ANTI JOIN setting -SET anti_join_select_star_compatibility = 1; +SET anti_join_compatibility = 1; -- LEFT ANTI JOIN with false condition: all left rows returned, only left columns SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; @@ -24,7 +24,7 @@ SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false; SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- Test SEMI JOIN setting -SET semi_join_select_star_compatibility = 1; +SET semi_join_compatibility = 1; -- LEFT SEMI JOIN with true condition: matched rows, only left columns SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; @@ -40,4 +40,4 @@ SELECT t1.*, t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON f -- Default behavior (both settings = 0): returns columns from both sides SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false -SETTINGS anti_join_select_star_compatibility = 0; +SETTINGS anti_join_compatibility = 0; From 67fbb3d0cfbf103cb8f1e897d58e0f9d1e64f82e Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Sun, 1 Mar 2026 01:52:20 +0800 Subject: [PATCH 005/272] move new settings to 26.3 section --- src/Core/SettingsChangesHistory.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 97e0418fbd67..bc018d90ae61 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -49,6 +49,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"use_partition_key", true, true, "Alias for setting 'use_partition_pruning'."}, {"mysql_datatypes_support_level", "", "decimal,datetime64,date2Date32", "Enable modern MySQL type mappings by default."}, {"allow_experimental_json_lazy_type_hints", false, false, "New experimental setting for lazy JSON type hints"}, + {"semi_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, + {"anti_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, }); addSettingsChanges(settings_changes_history, "26.2", { @@ -80,8 +82,6 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"jemalloc_profile_text_symbolize_with_inline", true, true, "New setting to control whether to include inline frames when symbolizing jemalloc heap profile. When enabled, inline frames are included at the cost of slower symbolization; when disabled, they are skipped for faster output"}, {"jemalloc_profile_text_collapsed_use_count", false, false, "New setting to aggregate by allocation count instead of bytes in the collapsed jemalloc heap profile format"}, {"opentelemetry_start_keeper_trace_probability", "auto", "auto", "New setting"}, - {"semi_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, - {"anti_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, }); addSettingsChanges(settings_changes_history, "26.1", { From 7b07373f8e4500539dc250b43220e3ff54c7c2c7 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Thu, 12 Mar 2026 15:03:08 +0800 Subject: [PATCH 006/272] Restrict column access from non-preserved side of SEMI/ANTI JOIN --- src/Analyzer/Resolve/IdentifierResolveScope.h | 2 + src/Analyzer/Resolve/IdentifierResolver.cpp | 63 +++++++++++- src/Analyzer/Resolve/IdentifierResolver.h | 25 +++++ src/Analyzer/Resolve/QueryAnalyzer.cpp | 75 +++++++++++++++ ...13_semi_anti_join_column_access.reference} | 10 +- .../03913_semi_anti_join_column_access.sql | 95 +++++++++++++++++++ .../03913_semi_anti_join_select_star.sql | 43 --------- 7 files changed, 266 insertions(+), 47 deletions(-) rename tests/queries/0_stateless/{03913_semi_anti_join_select_star.reference => 03913_semi_anti_join_column_access.reference} (52%) create mode 100644 tests/queries/0_stateless/03913_semi_anti_join_column_access.sql delete mode 100644 tests/queries/0_stateless/03913_semi_anti_join_select_star.sql diff --git a/src/Analyzer/Resolve/IdentifierResolveScope.h b/src/Analyzer/Resolve/IdentifierResolveScope.h index 18c6150e5f1f..f20af2298891 100644 --- a/src/Analyzer/Resolve/IdentifierResolveScope.h +++ b/src/Analyzer/Resolve/IdentifierResolveScope.h @@ -184,6 +184,8 @@ struct IdentifierResolveScope /// Join retutns NULLs instead of default values bool join_use_nulls = false; bool allow_resolve_from_using = true; + /// True when resolving JOIN ON expression (where both sides should be accessible) + bool resolving_join_on_expression = false; /// JOINs count size_t joins_count = 0; diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index 4b5fc7a9ff54..d010e922a38c 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -42,6 +42,8 @@ namespace Setting extern const SettingsSeconds lock_acquire_timeout; extern const SettingsBool single_join_prefer_left_table; extern const SettingsBool analyzer_compatibility_allow_compound_identifiers_in_unflatten_nested; + extern const SettingsBool semi_join_compatibility; + extern const SettingsBool anti_join_compatibility; } namespace ErrorCodes @@ -976,12 +978,60 @@ QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, return function_node; } +/// Helper structure to check SEMI/ANTI JOIN side access restrictions +SemiAntiJoinSideChecker::SemiAntiJoinSideChecker(JoinStrictness strictness, JoinKind kind, const ContextPtr & context, bool resolving_join_on_expression) + : resolving_join_on(resolving_join_on_expression) +{ + is_semi = strictness == JoinStrictness::Semi; + is_anti = strictness == JoinStrictness::Anti; + if (!is_semi && !is_anti) + return; + const auto & settings = context->getSettingsRef(); + bool skip_non_preserved_side = + (is_semi && settings[Setting::semi_join_compatibility]) + || (is_anti && settings[Setting::anti_join_compatibility]); + skip_left = skip_non_preserved_side && isRight(kind); + skip_right = skip_non_preserved_side && isLeft(kind); +} + +bool SemiAntiJoinSideChecker::shouldSkipSide(JoinTableSide side) const +{ + /// In JOIN ON expression, both sides should be accessible + if (resolving_join_on) + return false; + return (skip_left && side == JoinTableSide::Left) || (skip_right && side == JoinTableSide::Right); +} + +void SemiAntiJoinSideChecker::throwIfTableAccessDenied( + const JoinNode & join_node, + const IQueryTreeNode & table_expression_node, + const IQueryTreeNode & node_for_error_message, + const IQueryTreeNode & scope_node) const +{ + if (!skip_left && !skip_right) + return; + bool is_left_side = table_expression_node.isEqual(*join_node.getLeftTableExpression()); + bool is_right_side = table_expression_node.isEqual(*join_node.getRightTableExpression()); + if ((skip_left && is_left_side) || (skip_right && is_right_side)) + { + const char * join_type_str = is_semi ? "SEMI" : "ANTI"; + const char * side_str = skip_right ? "right" : "left"; + throw Exception(ErrorCodes::UNKNOWN_IDENTIFIER, + "Cannot access columns from the {} side of {} JOIN using qualified matcher {}. In scope {}", + side_str, + join_type_str, + node_for_error_message.formatASTForErrorMessage(), + scope_node.formatASTForErrorMessage()); + } +} + IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const IdentifierLookup & identifier_lookup, const QueryTreeNodePtr & table_expression_node, IdentifierResolveScope & scope) { const auto & from_join_node = table_expression_node->as(); JoinKind join_kind = from_join_node.getKind(); + JoinStrictness join_strictness = from_join_node.getStrictness(); bool join_node_in_resolve_process = scope.table_expressions_in_resolve_process.contains(table_expression_node.get()); std::unordered_map join_using_column_name_to_column_node; @@ -996,8 +1046,15 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I } } - auto try_resolve_identifier_from_join_tree_node = [&](const QueryTreeNodePtr & join_tree_node, bool may_be_override_by_using_column) + /// Check SEMI/ANTI JOIN side access restrictions + SemiAntiJoinSideChecker side_checker(join_strictness, join_kind, scope.context, scope.resolving_join_on_expression); + + auto try_resolve_identifier_from_join_tree_node = [&](const QueryTreeNodePtr & join_tree_node, bool may_be_override_by_using_column, JoinTableSide side) { + /// Early check: if this side should be skipped, return immediately + if (side_checker.shouldSkipSide(side)) + return QueryTreeNodePtr{}; + if (may_be_override_by_using_column && !join_using_column_name_to_column_node.empty()) scope.join_using_columns.push_back(&join_using_column_name_to_column_node); @@ -1009,8 +1066,8 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I return std::move(res.resolved_identifier); }; - auto left_resolved_identifier = try_resolve_identifier_from_join_tree_node(from_join_node.getLeftTableExpression(), join_kind == JoinKind::Right); - auto right_resolved_identifier = try_resolve_identifier_from_join_tree_node(from_join_node.getRightTableExpression(), join_kind != JoinKind::Right); + auto left_resolved_identifier = try_resolve_identifier_from_join_tree_node(from_join_node.getLeftTableExpression(), join_kind == JoinKind::Right, JoinTableSide::Left); + auto right_resolved_identifier = try_resolve_identifier_from_join_tree_node(from_join_node.getRightTableExpression(), join_kind != JoinKind::Right, JoinTableSide::Right); if (!identifier_lookup.isExpressionLookup()) { diff --git a/src/Analyzer/Resolve/IdentifierResolver.h b/src/Analyzer/Resolve/IdentifierResolver.h index f62c4a169dbf..ea1da5e75ca9 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.h +++ b/src/Analyzer/Resolve/IdentifierResolver.h @@ -24,6 +24,31 @@ using ProjectionNames = std::vector; struct Settings; +/// Checks whether access to a specific side of a SEMI/ANTI JOIN is allowed. +/// Used both during identifier resolution and qualified matcher resolution. +struct SemiAntiJoinSideChecker +{ + bool is_semi = false; + bool is_anti = false; + bool skip_left = false; + bool skip_right = false; + bool resolving_join_on = false; + + SemiAntiJoinSideChecker() = default; + + SemiAntiJoinSideChecker(JoinStrictness strictness, JoinKind kind, const ContextPtr & context, bool resolving_join_on_expression); + + bool shouldSkipSide(JoinTableSide side) const; + + /// Check access by comparing table expression nodes directly. + /// Used when the side is not known upfront (e.g. qualified matcher resolution). + void throwIfTableAccessDenied( + const JoinNode & join_node, + const IQueryTreeNode & table_expression_node, + const IQueryTreeNode & node_for_error_message, + const IQueryTreeNode & scope_node) const; +}; + class IdentifierResolver { public: diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 47bd6db9d568..462b062aaaec 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -37,6 +38,8 @@ #include #include +#include + #include #include #include @@ -1654,6 +1657,70 @@ void QueryAnalyzer::updateMatchedColumnsFromJoinUsing( } } +/** Check if a table expression is from the non-preserved side of a SEMI or ANTI JOIN. + * Throws an exception if access is not allowed. + * Traverses the JOIN tree to find the relevant SEMI/ANTI JOIN node. + */ +void checkSemiAntiJoinTableAccess( + const QueryTreeNodePtr & table_expression_node, + const IdentifierResolveScope & scope, + const QueryTreeNodePtr & node_for_error_message) +{ + const auto * nearest_query_scope = scope.getNearestQueryScope(); + if (!nearest_query_scope) + return; + auto * query_node = nearest_query_scope->scope_node->as(); + if (!query_node || !query_node->getJoinTree()) + return; + + /// Traverse the JOIN tree to find SEMI/ANTI JOIN nodes that contain the table expression + std::stack stack; + stack.push(query_node->getJoinTree().get()); + + while (!stack.empty()) + { + const auto * current = stack.top(); + stack.pop(); + + const auto * join_node = const_cast(current)->as(); + if (!join_node) + continue; + + /// Check if this is a SEMI or ANTI JOIN + if (join_node->getStrictness() != JoinStrictness::Semi && join_node->getStrictness() != JoinStrictness::Anti) + { + /// Not a SEMI/ANTI JOIN, continue traversing + stack.push(join_node->getLeftTableExpression().get()); + stack.push(join_node->getRightTableExpression().get()); + continue; + } + + /// Check if the table expression is from this JOIN + bool is_from_left = isFromJoinTree(table_expression_node.get(), join_node->getLeftTableExpression().get()); + bool is_from_right = isFromJoinTree(table_expression_node.get(), join_node->getRightTableExpression().get()); + + if (is_from_left || is_from_right) + { + /// Found the relevant SEMI/ANTI JOIN, check access + SemiAntiJoinSideChecker checker( + join_node->getStrictness(), + join_node->getKind(), + scope.context, + scope.resolving_join_on_expression); + checker.throwIfTableAccessDenied( + *join_node, + *table_expression_node, + *node_for_error_message, + *scope.scope_node); + return; + } + + /// Continue traversing + stack.push(join_node->getLeftTableExpression().get()); + stack.push(join_node->getRightTableExpression().get()); + } +} + /** Resolve qualified tree matcher. * * First try to match qualified identifier to expression. If qualified identifier matched expression node then @@ -1740,6 +1807,9 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveQualifiedMatcher(Qu scope.scope_node->formatASTForErrorMessage()); } + /// Check if the table is from the non-preserved side of a SEMI or ANTI JOIN + checkSemiAntiJoinTableAccess(table_expression_node, scope, matcher_node); + NamesAndTypes matched_columns; auto * table_expression_query_node = table_expression_node->as(); @@ -4419,7 +4489,12 @@ void QueryAnalyzer::resolveJoin(QueryTreeNodePtr & join_node, IdentifierResolveS { expressions_visitor.visit(join_node_typed.getJoinExpression()); auto join_expression = join_node_typed.getJoinExpression(); + + /// Set flag to allow access to both sides in JOIN ON expression + scope.resolving_join_on_expression = true; + SCOPE_EXIT(scope.resolving_join_on_expression = false); resolveExpressionNode(join_expression, scope, false /*allow_lambda_expression*/, false /*allow_table_expression*/); + join_node_typed.getJoinExpression() = std::move(join_expression); } else if (join_node_typed.isUsingJoinExpression()) diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference similarity index 52% rename from tests/queries/0_stateless/03913_semi_anti_join_select_star.reference rename to tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 11e675b1b5e7..8d2270f97eb1 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_select_star.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -4,5 +4,13 @@ 1 2 1 2 +1 1 0 -1 0 +1 +1 +1 +2 +1 +2 +3 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql new file mode 100644 index 000000000000..ec3bc454053a --- /dev/null +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -0,0 +1,95 @@ +-- Test SEMI/ANTI JOIN column access restrictions with SQL standard semantics. +-- When semi_join_compatibility or anti_join_compatibility settings are enabled, +-- only columns from the preserved side are accessible in: +-- 1. SELECT clause (SELECT *, qualified matchers like t2.*) +-- 2. WHERE clause +-- +-- Preserved side: +-- - LEFT SEMI/ANTI JOIN: left side preserved, right side not accessible +-- - RIGHT SEMI/ANTI JOIN: right side preserved, left side not accessible +-- +-- Note: JOIN ON expressions can access both sides regardless of settings. + +-- only with new analyzer +SET allow_experimental_analyzer = 1; + +-- Test ANTI JOIN setting +SET anti_join_compatibility = 1; +SET semi_join_compatibility = 0; + +-- LEFT ANTI JOIN with false condition: all left rows returned, only left columns +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; + +-- LEFT ANTI JOIN with true condition: no rows returned +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON true; + +-- Multiple columns on left side +SELECT * FROM (SELECT 1 AS a, 2 AS c) t1 LEFT ANTI JOIN (SELECT 3 AS b, 4 AS d) t2 ON false; + +-- RIGHT ANTI JOIN: only right columns +SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false; + +-- SEMI JOIN is not affected by anti_join setting: returns columns from both sides +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- Test SEMI JOIN setting +SET semi_join_compatibility = 1; + +-- LEFT SEMI JOIN with true condition: matched rows, only left columns +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- LEFT SEMI JOIN with false condition: no rows +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON false; + +-- RIGHT SEMI JOIN: only right columns +SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- Explicit column reference: preserved side works, non-preserved side fails +SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; -- { serverError UNKNOWN_IDENTIFIER } + +-- Default behavior (both settings = 0): returns columns from both sides +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false +SETTINGS anti_join_compatibility = 0; + +-- Test that non-preserved side columns are not accessible with qualified references +SELECT d.* FROM (SELECT 1 AS id, 2 AS value) AS l SEMI LEFT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } +SELECT l.* FROM (SELECT 1 AS id, 2 AS value) AS l SEMI RIGHT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } +SELECT d.* FROM (SELECT 1 AS id, 2 AS value) AS l ANTI LEFT JOIN (SELECT 2 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } +SELECT l.* FROM (SELECT 2 AS id, 2 AS value) AS l ANTI RIGHT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } + +-- Test USING column access +SELECT id FROM (SELECT 1 AS id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING id; +SELECT l.id FROM (SELECT 1 AS id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING id; +SELECT d.id FROM (SELECT 1 AS id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } + +-- Test USING with non-existent columns +SELECT * FROM (SELECT 1 AS other_id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING (id); -- { serverError UNKNOWN_IDENTIFIER } + +-- Test WHERE clause: non-preserved side columns are not accessible +-- LEFT SEMI JOIN: cannot reference right side columns in WHERE +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t1.a = 1; + +-- RIGHT SEMI JOIN: cannot reference left side columns in WHERE +SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t1.a = 1; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t2.b = 2; + +-- LEFT ANTI JOIN: cannot reference right side columns in WHERE +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t1.a = 1; + +-- RIGHT ANTI JOIN: cannot reference left side columns in WHERE +SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t1.a = 1; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t2.b = 2; + +-- Test nested JOINs: SEMI/ANTI JOIN restrictions apply only within their scope +-- Nested: (t1 LEFT SEMI JOIN t2) LEFT JOIN t3 +-- t2 is non-preserved in inner SEMI JOIN, t3 is in outer regular JOIN +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON true; + +-- Nested: t1 LEFT JOIN (t2 LEFT SEMI JOIN t3) +-- t3 is non-preserved in SEMI JOIN, t1 is in outer regular JOIN +SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT JOIN (SELECT * FROM (SELECT 2 AS b) t2 LEFT SEMI JOIN (SELECT 3 AS c) t3 ON true) sub ON true; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT JOIN (SELECT * FROM (SELECT 2 AS b) t2 LEFT SEMI JOIN (SELECT 3 AS c) t3 ON true) sub ON true; diff --git a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql b/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql deleted file mode 100644 index dd44fd3e4e06..000000000000 --- a/tests/queries/0_stateless/03913_semi_anti_join_select_star.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Test that SELECT * in SEMI/ANTI JOIN follows SQL standard semantics --- when the corresponding setting is enabled: --- only returns columns from the preserved side - --- only with new analyzer -SET allow_experimental_analyzer = 1; - --- Test ANTI JOIN setting -SET anti_join_compatibility = 1; - --- LEFT ANTI JOIN with false condition: all left rows returned, only left columns -SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; - --- LEFT ANTI JOIN with true condition: no rows returned -SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON true; - --- Multiple columns on left side -SELECT * FROM (SELECT 1 AS a, 2 AS c) t1 LEFT ANTI JOIN (SELECT 3 AS b, 4 AS d) t2 ON false; - --- RIGHT ANTI JOIN: only right columns -SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false; - --- SEMI JOIN is not affected by anti_join setting: returns columns from both sides -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; - --- Test SEMI JOIN setting -SET semi_join_compatibility = 1; - --- LEFT SEMI JOIN with true condition: matched rows, only left columns -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; - --- LEFT SEMI JOIN with false condition: no rows -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON false; - --- RIGHT SEMI JOIN: only right columns -SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true; - --- Explicit column reference still works (t1.*, t2.* are qualified matchers) -SELECT t1.*, t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; - --- Default behavior (both settings = 0): returns columns from both sides -SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false -SETTINGS anti_join_compatibility = 0; From 0ff087320bd9151cb759822143fb5300806e0834 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Thu, 12 Mar 2026 20:08:44 +0800 Subject: [PATCH 007/272] resolve review comments --- src/Analyzer/Resolve/IdentifierResolveScope.h | 5 ++-- src/Analyzer/Resolve/IdentifierResolver.cpp | 27 +++++++++++-------- src/Analyzer/Resolve/IdentifierResolver.h | 15 ++++++----- src/Analyzer/Resolve/QueryAnalyzer.cpp | 14 +++++----- src/Core/Settings.cpp | 13 ++++++--- ...913_semi_anti_join_column_access.reference | 1 + .../03913_semi_anti_join_column_access.sql | 8 ++++++ 7 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolveScope.h b/src/Analyzer/Resolve/IdentifierResolveScope.h index f20af2298891..02c368d0df7c 100644 --- a/src/Analyzer/Resolve/IdentifierResolveScope.h +++ b/src/Analyzer/Resolve/IdentifierResolveScope.h @@ -184,8 +184,9 @@ struct IdentifierResolveScope /// Join retutns NULLs instead of default values bool join_use_nulls = false; bool allow_resolve_from_using = true; - /// True when resolving JOIN ON expression (where both sides should be accessible) - bool resolving_join_on_expression = false; + /// Points to the JOIN node whose ON expression is currently being resolved (where both sides should be accessible) + /// nullptr when not resolving any JOIN ON expression + const IQueryTreeNode * resolving_join_on_expression = nullptr; /// JOINs count size_t joins_count = 0; diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index d010e922a38c..cdd03548a42f 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -979,8 +979,12 @@ QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, } /// Helper structure to check SEMI/ANTI JOIN side access restrictions -SemiAntiJoinSideChecker::SemiAntiJoinSideChecker(JoinStrictness strictness, JoinKind kind, const ContextPtr & context, bool resolving_join_on_expression) - : resolving_join_on(resolving_join_on_expression) +SemiAntiJoinSideChecker::SemiAntiJoinSideChecker( + const JoinNode & join_node, + JoinStrictness strictness, + JoinKind kind, + const ContextPtr & context, + const IQueryTreeNode * resolving_join_on_expression) { is_semi = strictness == JoinStrictness::Semi; is_anti = strictness == JoinStrictness::Anti; @@ -992,27 +996,28 @@ SemiAntiJoinSideChecker::SemiAntiJoinSideChecker(JoinStrictness strictness, Join || (is_anti && settings[Setting::anti_join_compatibility]); skip_left = skip_non_preserved_side && isRight(kind); skip_right = skip_non_preserved_side && isLeft(kind); + + /// If we are resolving the ON expression of THIS specific JOIN node, allow access to both sides + if (resolving_join_on_expression && resolving_join_on_expression == &join_node) + { + skip_left = false; + skip_right = false; + } } bool SemiAntiJoinSideChecker::shouldSkipSide(JoinTableSide side) const { - /// In JOIN ON expression, both sides should be accessible - if (resolving_join_on) - return false; return (skip_left && side == JoinTableSide::Left) || (skip_right && side == JoinTableSide::Right); } void SemiAntiJoinSideChecker::throwIfTableAccessDenied( - const JoinNode & join_node, - const IQueryTreeNode & table_expression_node, + JoinTableSide side, const IQueryTreeNode & node_for_error_message, const IQueryTreeNode & scope_node) const { if (!skip_left && !skip_right) return; - bool is_left_side = table_expression_node.isEqual(*join_node.getLeftTableExpression()); - bool is_right_side = table_expression_node.isEqual(*join_node.getRightTableExpression()); - if ((skip_left && is_left_side) || (skip_right && is_right_side)) + if ((skip_left && side == JoinTableSide::Left) || (skip_right && side == JoinTableSide::Right)) { const char * join_type_str = is_semi ? "SEMI" : "ANTI"; const char * side_str = skip_right ? "right" : "left"; @@ -1047,7 +1052,7 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I } /// Check SEMI/ANTI JOIN side access restrictions - SemiAntiJoinSideChecker side_checker(join_strictness, join_kind, scope.context, scope.resolving_join_on_expression); + SemiAntiJoinSideChecker side_checker(from_join_node, join_strictness, join_kind, scope.context, scope.resolving_join_on_expression); auto try_resolve_identifier_from_join_tree_node = [&](const QueryTreeNodePtr & join_tree_node, bool may_be_override_by_using_column, JoinTableSide side) { diff --git a/src/Analyzer/Resolve/IdentifierResolver.h b/src/Analyzer/Resolve/IdentifierResolver.h index ea1da5e75ca9..f1f217adde84 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.h +++ b/src/Analyzer/Resolve/IdentifierResolver.h @@ -32,19 +32,22 @@ struct SemiAntiJoinSideChecker bool is_anti = false; bool skip_left = false; bool skip_right = false; - bool resolving_join_on = false; SemiAntiJoinSideChecker() = default; - SemiAntiJoinSideChecker(JoinStrictness strictness, JoinKind kind, const ContextPtr & context, bool resolving_join_on_expression); + SemiAntiJoinSideChecker( + const JoinNode & join_node, + JoinStrictness strictness, + JoinKind kind, + const ContextPtr & context, + const IQueryTreeNode * resolving_join_on_expression); bool shouldSkipSide(JoinTableSide side) const; - /// Check access by comparing table expression nodes directly. - /// Used when the side is not known upfront (e.g. qualified matcher resolution). + /// Throw if access to the given side of a SEMI/ANTI JOIN is denied. + /// The caller is responsible for determining the correct side (e.g. via isFromJoinTree). void throwIfTableAccessDenied( - const JoinNode & join_node, - const IQueryTreeNode & table_expression_node, + JoinTableSide side, const IQueryTreeNode & node_for_error_message, const IQueryTreeNode & scope_node) const; }; diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 462b062aaaec..4eddce638e1c 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -1703,15 +1703,13 @@ void checkSemiAntiJoinTableAccess( { /// Found the relevant SEMI/ANTI JOIN, check access SemiAntiJoinSideChecker checker( + *join_node, join_node->getStrictness(), join_node->getKind(), scope.context, scope.resolving_join_on_expression); - checker.throwIfTableAccessDenied( - *join_node, - *table_expression_node, - *node_for_error_message, - *scope.scope_node); + JoinTableSide side = is_from_left ? JoinTableSide::Left : JoinTableSide::Right; + checker.throwIfTableAccessDenied(side, *node_for_error_message, *scope.scope_node); return; } @@ -4490,9 +4488,9 @@ void QueryAnalyzer::resolveJoin(QueryTreeNodePtr & join_node, IdentifierResolveS expressions_visitor.visit(join_node_typed.getJoinExpression()); auto join_expression = join_node_typed.getJoinExpression(); - /// Set flag to allow access to both sides in JOIN ON expression - scope.resolving_join_on_expression = true; - SCOPE_EXIT(scope.resolving_join_on_expression = false); + /// Set pointer to current JOIN node to allow access to both sides in its ON expression + scope.resolving_join_on_expression = join_node.get(); + SCOPE_EXIT(scope.resolving_join_on_expression = nullptr); resolveExpressionNode(join_expression, scope, false /*allow_lambda_expression*/, false /*allow_table_expression*/); join_node_typed.getJoinExpression() = std::move(join_expression); diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index be4f99d7c3ed..9b532bd88553 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7075,12 +7075,19 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. )", 0) \ DECLARE(Bool, semi_join_compatibility, false, R"( -When enabled, `SEMI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT SEMI JOIN` only left table columns are returned, for `RIGHT SEMI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. +When enabled, `SEMI` JOIN restricts column access to the preserved side per SQL standard. +For `LEFT SEMI JOIN` only left table columns are accessible in `SELECT` and `WHERE` clauses (including qualified matchers like `t1.*`), for `RIGHT SEMI JOIN` only right table columns. +Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. +`JOIN ON` expressions can access both sides regardless of this setting. +When disabled, `SELECT *` returns columns from both sides (legacy behavior). )", 0) \ DECLARE(Bool, anti_join_compatibility, false, R"( -When enabled, `ANTI` JOIN only returns columns from the preserved side per SQL standard. For `LEFT ANTI JOIN` only left table columns are returned, for `RIGHT ANTI JOIN` only right table columns. When disabled, `SELECT *` returns columns from both sides (legacy behavior). Explicit qualified matchers like `SELECT t1.*, t2.*` are not affected by this setting. +When enabled, `ANTI` JOIN restricts column access to the preserved side per SQL standard. +For `LEFT ANTI JOIN` only left table columns are accessible in `SELECT` and `WHERE` clauses (including qualified matchers like `t1.*`), for `RIGHT ANTI JOIN` only right table columns. +Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. +`JOIN ON` expressions can access both sides regardless of this setting. +When disabled, `SELECT *` returns columns from both sides (legacy behavior). )", 0) \ - \ DECLARE(Timezone, session_timezone, "", R"( Sets the implicit time zone of the current session or query. The implicit time zone is the time zone applied to values of type DateTime/DateTime64 which have no explicitly specified time zone. diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 8d2270f97eb1..5cf8ec5ed908 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -14,3 +14,4 @@ 2 3 1 +1 0 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index ec3bc454053a..d00f7bef932d 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -93,3 +93,11 @@ SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LE -- t3 is non-preserved in SEMI JOIN, t1 is in outer regular JOIN SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT JOIN (SELECT * FROM (SELECT 2 AS b) t2 LEFT SEMI JOIN (SELECT 3 AS c) t3 ON true) sub ON true; -- { serverError UNKNOWN_IDENTIFIER } SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT JOIN (SELECT * FROM (SELECT 2 AS b) t2 LEFT SEMI JOIN (SELECT 3 AS c) t3 ON true) sub ON true; + +-- Nested: (t1 LEFT SEMI JOIN t2) LEFT JOIN t3 +-- In outer JOIN's ON clause, t2 (non-preserved in inner SEMI JOIN) should still be inaccessible +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON t2.b = t3.c; -- { serverError UNKNOWN_IDENTIFIER } +-- But t1 (preserved in inner SEMI JOIN) should be accessible in outer JOIN's ON clause +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON t1.a = t3.c; +-- Test that inner SEMI JOIN's own ON expression can still access both sides +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON t1.a = t2.b LEFT JOIN (SELECT 3 AS c) t3 ON t1.a = t3.c; From fb9fed7e0106c4b1288b484cd0ab285b2bdfd742 Mon Sep 17 00:00:00 2001 From: Christian Endres Date: Thu, 22 Jan 2026 14:51:07 +0000 Subject: [PATCH 008/272] Add timeout to Context::getZooKeeper We observed that queries requiring ZooKeeper (e.g. inserts, select from `system.zookeeper`) can cause a cluster to get stuck by taking all concurrency slots when ZooKeeper is down. This is is because they all compete a for a mutex in `Context::getZooKeeper`, and once it is acquired, session startup takes a while to fail with connection timeouts. This commit adds a configurable timeout for acquiring that mutex so that queries fail fast if another thread or query is already trying to establish the ZooKeeper connection. The timeout is not added to other places where that mutex is acquired because they don't seem to be on a similarly critcal path. --- src/Core/Settings.cpp | 8 ++ src/Core/SettingsChangesHistory.cpp | 1 + src/Interpreters/Context.cpp | 16 ++- .../__init__.py | 0 .../configs/disable_ddl.xml | 4 + .../configs/users.xml | 10 ++ .../configs/zookeeper.xml | 12 ++ .../test.py | 110 ++++++++++++++++++ 8 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_zookeeper_lock_acquire_timeout/__init__.py create mode 100644 tests/integration/test_zookeeper_lock_acquire_timeout/configs/disable_ddl.xml create mode 100644 tests/integration/test_zookeeper_lock_acquire_timeout/configs/users.xml create mode 100644 tests/integration/test_zookeeper_lock_acquire_timeout/configs/zookeeper.xml create mode 100644 tests/integration/test_zookeeper_lock_acquire_timeout/test.py diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index 129d2dd67772..5c81a0273ab8 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -4667,6 +4667,14 @@ Possible values: - Positive integer (in seconds). - 0 — No locking timeout. +)", 0) \ + DECLARE(Milliseconds, get_zookeeper_lock_acquire_timeout_ms, DBMS_DEFAULT_LOCK_ACQUIRE_TIMEOUT_SEC * 1000, R"( +Defines how many milliseconds a locking request in Context::getZooKeeper should wait before failing. + +Possible values: + +- Positive integer (in milliseconds). +- 0 — No locking timeout. )", 0) \ DECLARE(Bool, materialize_ttl_after_modify, true, R"( Apply TTL for old data, after ALTER MODIFY TTL query diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 7b6840a6da29..2e13609a3e5a 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -56,6 +56,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"webassembly_udf_max_instances", 32, 32, "New setting to limit the number of parallel WebAssembly UDF instances per function."}, {"mysql_datatypes_support_level", "", "decimal,datetime64,date2Date32", "Enable modern MySQL type mappings by default."}, {"allow_experimental_json_lazy_type_hints", false, false, "New experimental setting for lazy JSON type hints"}, + {"get_zookeeper_lock_acquire_timeout_ms", 0, DBMS_DEFAULT_LOCK_ACQUIRE_TIMEOUT_SEC * 1000, "New setting"}, }); addSettingsChanges(settings_changes_history, "26.2", { diff --git a/src/Interpreters/Context.cpp b/src/Interpreters/Context.cpp index 12891d314e75..f5aed3138ffc 100644 --- a/src/Interpreters/Context.cpp +++ b/src/Interpreters/Context.cpp @@ -335,6 +335,7 @@ namespace Setting extern const SettingsBool parallel_replicas_only_with_analyzer; extern const SettingsBool enable_hdfs_pread; extern const SettingsUInt64 max_reverse_dictionary_lookup_cache_size_bytes; + extern const SettingsMilliseconds get_zookeeper_lock_acquire_timeout_ms; } namespace MergeTreeSetting @@ -412,6 +413,7 @@ namespace ErrorCodes extern const int SET_NON_GRANTED_ROLE; extern const int UNKNOWN_DISK; extern const int UNKNOWN_READ_METHOD; + extern const int TIMEOUT_EXCEEDED; } #define SHUTDOWN(log, desc, ptr, method) do \ @@ -441,10 +443,10 @@ struct ContextSharedPart : boost::noncopyable /// under context lock. mutable std::mutex storage_policies_mutex; /// Separate mutex for re-initialization of zookeeper session. This operation could take a long time and must not interfere with another operations. - mutable std::mutex zookeeper_mutex; + mutable std::timed_mutex zookeeper_mutex; - mutable zkutil::ZooKeeperPtr zookeeper TSA_GUARDED_BY(zookeeper_mutex); /// Client for ZooKeeper. - ConfigurationPtr zookeeper_config TSA_GUARDED_BY(zookeeper_mutex); /// Stores zookeeper configs + mutable zkutil::ZooKeeperPtr zookeeper; /// Client for ZooKeeper. Protected by zookeeper_mutex. + ConfigurationPtr zookeeper_config; /// Stores zookeeper configs. Protected by zookeeper_mutex. ConfigurationPtr sensitive_data_masker_config; @@ -4841,8 +4843,12 @@ void recordZooKeeperConnectionLoss() zkutil::ZooKeeperPtr Context::getZooKeeper() const { auto component_guard = Coordination::setCurrentComponent("Context::getZooKeeper"); - std::lock_guard lock(shared->zookeeper_mutex); - + auto lock_acquire_timeout = getSettingsRef()[Setting::get_zookeeper_lock_acquire_timeout_ms]; + if (hasQueryContext()) + lock_acquire_timeout = getQueryContext()->getSettingsRef()[Setting::get_zookeeper_lock_acquire_timeout_ms]; + std::unique_lock lock(shared->zookeeper_mutex, std::defer_lock); + if (!lock.try_lock_for(std::chrono::milliseconds(lock_acquire_timeout.totalMilliseconds()))) + throw Exception(ErrorCodes::TIMEOUT_EXCEEDED, "Timeout exceeded while acquiring ZooKeeper lock ({} ms)", lock_acquire_timeout.totalMilliseconds()); const auto & config = shared->zookeeper_config ? *shared->zookeeper_config : getConfigRef(); if (!shared->zookeeper) diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/__init__.py b/tests/integration/test_zookeeper_lock_acquire_timeout/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/configs/disable_ddl.xml b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/disable_ddl.xml new file mode 100644 index 000000000000..7581c89ee1fa --- /dev/null +++ b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/disable_ddl.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/configs/users.xml b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/users.xml new file mode 100644 index 000000000000..d3a58e0a8db9 --- /dev/null +++ b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/users.xml @@ -0,0 +1,10 @@ + + + + + 1 + 0 + 0 + + + diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/configs/zookeeper.xml b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/zookeeper.xml new file mode 100644 index 000000000000..32827d9a41e0 --- /dev/null +++ b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/zookeeper.xml @@ -0,0 +1,12 @@ + + + + zoo1 + 2181 + + 10000 + + 5000 + 5000 + + diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/test.py b/tests/integration/test_zookeeper_lock_acquire_timeout/test.py new file mode 100644 index 000000000000..39b11241128e --- /dev/null +++ b/tests/integration/test_zookeeper_lock_acquire_timeout/test.py @@ -0,0 +1,110 @@ +""" +Test for get_zookeeper_lock_acquire_timeout_ms setting. + +When ZooKeeper is unavailable and one thread holds the zookeeper_mutex while trying +to reconnect, other threads should fail fast with TIMEOUT_EXCEEDED rather than +blocking indefinitely. +""" + +import pytest +import time +import concurrent.futures +from helpers.cluster import ClickHouseCluster, QueryRuntimeException + +cluster = ClickHouseCluster(__file__, zookeeper_config_path="configs/zookeeper.xml") + +node = cluster.add_instance( + "node", + with_zookeeper=True, + main_configs=["configs/zookeeper.xml", "configs/disable_ddl.xml"], + user_configs=["configs/users.xml"], + stay_alive=True, +) + + +@pytest.fixture(scope="module") +def started_cluster(): + try: + cluster.start() + yield cluster + finally: + cluster.shutdown() + + +def test_zookeeper_lock_acquire_timeout(started_cluster): + """ + Test that queries fail with TIMEOUT_EXCEEDED when they can't acquire + the zookeeper_mutex within the configured timeout. + + Strategy: + 1. Pause ZooKeeper so getZooKeeper() blocks indefinitely on reconnection + 2. Fire one query with long timeout (holds mutex while blocked on ZK) + 3. Fire concurrent queries with short timeout - they should fail fast + 4. Verify the short-timeout queries fail with TIMEOUT_EXCEEDED quickly + """ + node.query("SELECT * FROM system.zookeeper WHERE path = '/' LIMIT 1") + with cluster.pause_container("zoo1"): + # once this query fails, it means the Zookeeper session is expired + with pytest.raises(QueryRuntimeException) as e: + node.query("SELECT * FROM system.zookeeper WHERE path = '/' LIMIT 1") + assert "KEEPER_EXCEPTION" in str(e) + + long_timeout_ms = 30000 + short_timeout_ms = 200 + max_short_query_duration_seconds = 2 # they should fail close to short_timeout_ms, but give some buffer + num_short_queries = 5 + + results = [] + + def run_query(query_id, lock_acquire_timeout_ms): + start = time.time() + try: + node.query( + f"SELECT '{query_id}', * FROM system.zookeeper WHERE path = '/' LIMIT 1", + settings={"get_zookeeper_lock_acquire_timeout_ms": lock_acquire_timeout_ms}, + timeout=(lock_acquire_timeout_ms / 1000) * 10, + query_id=query_id, + ) + return ("success", time.time() - start, None) + except Exception as e: + return ("error", time.time() - start, str(e)) + + # Fire queries concurrently: + # - One with long timeout (will hold the mutex) + # - Several with short timeout (should fail fast) + with concurrent.futures.ThreadPoolExecutor(max_workers=num_short_queries + 1) as executor: + # Start the long-timeout query first + long_future = executor.submit(run_query, "long", long_timeout_ms) + + # Give it a moment to acquire the mutex + time.sleep(1) + + # Now fire short-timeout queries + short_futures = [executor.submit(run_query, f"short_{i}", short_timeout_ms) for i in range(num_short_queries)] + + for f in concurrent.futures.as_completed(short_futures, timeout=10): + results.append(f.result()) + + long_result = long_future.result() + assert long_result[0] == "error", f"Expected error, got {long_result[0]}" + assert "DB::Exception: All connection tries failed while connecting to ZooKeeper." in long_result[2], f"Expected 'all connection tries failed' error, got {long_result[2]}" + + # Analyze results from short-timeout queries + # Look for our specific mutex acquire timeout error message + assert len(results) == num_short_queries, f"Expected {num_short_queries} results, got {len(results)}" + for r in results: + assert r[0] == "error", f"Expected error, got {r[0]}" + assert "acquiring ZooKeeper lock" in r[2], f"Expected 'acquiring ZooKeeper lock' error, got {r[2]}" + assert r[1] < max_short_query_duration_seconds, f"Expected timeout < {max_short_query_duration_seconds}s, got {r[1]}s" + + +def test_zookeeper_lock_acquire_timeout_success_when_no_contention(started_cluster): + """ + Verify that queries succeed normally when there's no lock contention, + even with a short timeout configured. + """ + result = node.query( + "SELECT count() FROM system.zookeeper WHERE path = '/'", + settings={"get_zookeeper_lock_acquire_timeout_ms": 100}, + ) + assert int(result.strip()) >= 0 From 843c9e45b380e0382b85f85c350bcf30563907dd Mon Sep 17 00:00:00 2001 From: Christian Endres Date: Fri, 13 Mar 2026 08:13:37 +0000 Subject: [PATCH 009/272] Ensure config reload from ZK on any exception --- src/Common/Config/ConfigReloader.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Common/Config/ConfigReloader.cpp b/src/Common/Config/ConfigReloader.cpp index 3b34594b3155..7f9c738a78e5 100644 --- a/src/Common/Config/ConfigReloader.cpp +++ b/src/Common/Config/ConfigReloader.cpp @@ -133,10 +133,10 @@ std::optional ConfigReloader::reloadIfNewer(bool loaded_config = config_processor.loadConfigWithZooKeeperIncludes( zk_node_cache.get(), zk_changed_event, fallback_to_preprocessed, is_config_changed); } - catch (const Coordination::Exception & e) + catch (const Coordination::Exception &) { - if (Coordination::isHardwareError(e.code)) - need_reload_from_zk = true; + // Always reload from ZK in case of an exception to ensure we don't miss any changes. + need_reload_from_zk = true; const auto message = getCurrentExceptionMessageAndPattern(/*with_stacktrace=*/true); auto exc = std::make_unique(message, ErrorCodes::CANNOT_LOAD_CONFIG); @@ -149,6 +149,9 @@ std::optional ConfigReloader::reloadIfNewer(bool } catch (...) { + // Always reload from ZK in case of an exception to ensure we don't miss any changes. + need_reload_from_zk = true; + const auto message = getCurrentExceptionMessageAndPattern(/*with_stacktrace=*/true); auto exc = std::make_unique(message, ErrorCodes::CANNOT_LOAD_CONFIG); From 95ca01dffe6db24ee6b74506a35666927961ff02 Mon Sep 17 00:00:00 2001 From: Christian Endres Date: Fri, 13 Mar 2026 11:25:24 +0000 Subject: [PATCH 010/272] Also add lock timeout for auxiliary zookeepers --- src/Interpreters/Context.cpp | 13 +++++--- .../configs/auxiliary_zookeepers.xml | 13 ++++++++ .../test.py | 30 +++++++++++-------- 3 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 tests/integration/test_zookeeper_lock_acquire_timeout/configs/auxiliary_zookeepers.xml diff --git a/src/Interpreters/Context.cpp b/src/Interpreters/Context.cpp index f5aed3138ffc..5518898bcb73 100644 --- a/src/Interpreters/Context.cpp +++ b/src/Interpreters/Context.cpp @@ -450,9 +450,9 @@ struct ContextSharedPart : boost::noncopyable ConfigurationPtr sensitive_data_masker_config; - mutable std::mutex auxiliary_zookeepers_mutex; - mutable std::map auxiliary_zookeepers TSA_GUARDED_BY(auxiliary_zookeepers_mutex); /// Map for auxiliary ZooKeeper clients. - ConfigurationPtr auxiliary_zookeepers_config TSA_GUARDED_BY(auxiliary_zookeepers_mutex); /// Stores auxiliary zookeepers configs + mutable std::timed_mutex auxiliary_zookeepers_mutex; + mutable std::map auxiliary_zookeepers; /// Map for auxiliary ZooKeeper clients. Protected by auxiliary_zookeepers_mutex. + ConfigurationPtr auxiliary_zookeepers_config; /// Stores auxiliary zookeepers configs. Protected by auxiliary_zookeepers_mutex. /// No lock required for interserver_io_host, interserver_io_port, interserver_scheme modified only during initialization String interserver_io_host; /// The host name by which this server is available for other servers. @@ -5120,7 +5120,12 @@ void Context::updateKeeperConfiguration([[maybe_unused]] const Poco::Util::Abstr zkutil::ZooKeeperPtr Context::getAuxiliaryZooKeeper(const String & name) const { auto component_guard = Coordination::setCurrentComponent("Context::getAuxiliaryZooKeeper"); - std::lock_guard lock(shared->auxiliary_zookeepers_mutex); + auto lock_acquire_timeout = getSettingsRef()[Setting::get_zookeeper_lock_acquire_timeout_ms]; + if (hasQueryContext()) + lock_acquire_timeout = getQueryContext()->getSettingsRef()[Setting::get_zookeeper_lock_acquire_timeout_ms]; + std::unique_lock lock(shared->auxiliary_zookeepers_mutex, std::defer_lock); + if (!lock.try_lock_for(std::chrono::milliseconds(lock_acquire_timeout.totalMilliseconds()))) + throw Exception(ErrorCodes::TIMEOUT_EXCEEDED, "Timeout exceeded while acquiring auxiliary ZooKeeper lock ({} ms)", lock_acquire_timeout.totalMilliseconds()); const auto config_name = "auxiliary_zookeepers." + name; auto zookeeper = shared->auxiliary_zookeepers.find(name); diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/configs/auxiliary_zookeepers.xml b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/auxiliary_zookeepers.xml new file mode 100644 index 000000000000..faeae25662c0 --- /dev/null +++ b/tests/integration/test_zookeeper_lock_acquire_timeout/configs/auxiliary_zookeepers.xml @@ -0,0 +1,13 @@ + + + + + zoo1 + 2181 + + 10000 + 5000 + 5000 + + + diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/test.py b/tests/integration/test_zookeeper_lock_acquire_timeout/test.py index 39b11241128e..cc3b6623140e 100644 --- a/tests/integration/test_zookeeper_lock_acquire_timeout/test.py +++ b/tests/integration/test_zookeeper_lock_acquire_timeout/test.py @@ -16,7 +16,7 @@ cluster = ClickHouseCluster(__file__, zookeeper_config_path="configs/zookeeper.x node = cluster.add_instance( "node", with_zookeeper=True, - main_configs=["configs/zookeeper.xml", "configs/disable_ddl.xml"], + main_configs=["configs/zookeeper.xml", "configs/disable_ddl.xml", "configs/auxiliary_zookeepers.xml"], user_configs=["configs/users.xml"], stay_alive=True, ) @@ -31,23 +31,27 @@ def started_cluster(): cluster.shutdown() -def test_zookeeper_lock_acquire_timeout(started_cluster): +@pytest.mark.parametrize("zk_name", ["default", "zookeeper2"]) +def test_zookeeper_lock_acquire_timeout(started_cluster, zk_name): """ Test that queries fail with TIMEOUT_EXCEEDED when they can't acquire - the zookeeper_mutex within the configured timeout. + the zookeeper_mutex (or auxiliary_zookeepers_mutex) within the configured timeout. Strategy: - 1. Pause ZooKeeper so getZooKeeper() blocks indefinitely on reconnection + 1. Pause ZooKeeper so getZooKeeper()/getAuxiliaryZooKeeper() blocks on reconnection 2. Fire one query with long timeout (holds mutex while blocked on ZK) 3. Fire concurrent queries with short timeout - they should fail fast 4. Verify the short-timeout queries fail with TIMEOUT_EXCEEDED quickly """ - node.query("SELECT * FROM system.zookeeper WHERE path = '/' LIMIT 1") + zk_filter = "" if zk_name == "default" else f" AND zookeeperName = '{zk_name}'" + base_query = f"SELECT * FROM system.zookeeper WHERE path = '/'{zk_filter} LIMIT 1" + + node.query(base_query) with cluster.pause_container("zoo1"): # once this query fails, it means the Zookeeper session is expired with pytest.raises(QueryRuntimeException) as e: - node.query("SELECT * FROM system.zookeeper WHERE path = '/' LIMIT 1") - assert "KEEPER_EXCEPTION" in str(e) + node.query(base_query) + assert "KEEPER_EXCEPTION" in str(e.value) long_timeout_ms = 30000 short_timeout_ms = 200 @@ -60,7 +64,7 @@ def test_zookeeper_lock_acquire_timeout(started_cluster): start = time.time() try: node.query( - f"SELECT '{query_id}', * FROM system.zookeeper WHERE path = '/' LIMIT 1", + f"SELECT '{query_id}', * FROM system.zookeeper WHERE path = '/'{zk_filter} LIMIT 1", settings={"get_zookeeper_lock_acquire_timeout_ms": lock_acquire_timeout_ms}, timeout=(lock_acquire_timeout_ms / 1000) * 10, query_id=query_id, @@ -94,17 +98,19 @@ def test_zookeeper_lock_acquire_timeout(started_cluster): assert len(results) == num_short_queries, f"Expected {num_short_queries} results, got {len(results)}" for r in results: assert r[0] == "error", f"Expected error, got {r[0]}" - assert "acquiring ZooKeeper lock" in r[2], f"Expected 'acquiring ZooKeeper lock' error, got {r[2]}" + assert "acquiring" in r[2] and "ZooKeeper lock" in r[2], f"Expected 'acquiring ... ZooKeeper lock' error, got {r[2]}" assert r[1] < max_short_query_duration_seconds, f"Expected timeout < {max_short_query_duration_seconds}s, got {r[1]}s" -def test_zookeeper_lock_acquire_timeout_success_when_no_contention(started_cluster): +@pytest.mark.parametrize("zk_name", ["default", "zookeeper2"]) +def test_zookeeper_lock_acquire_timeout_success_when_no_contention(started_cluster, zk_name): """ Verify that queries succeed normally when there's no lock contention, even with a short timeout configured. """ + zk_filter = "" if zk_name == "default" else f" AND zookeeperName = '{zk_name}'" result = node.query( - "SELECT count() FROM system.zookeeper WHERE path = '/'", + f"SELECT count() FROM system.zookeeper WHERE path = '/'{zk_filter}", settings={"get_zookeeper_lock_acquire_timeout_ms": 100}, ) - assert int(result.strip()) >= 0 + assert int(result.strip()) > 0 From eab61421aff4cf4ec026a26631f7c6f237190b34 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Mon, 16 Mar 2026 14:06:36 +0800 Subject: [PATCH 011/272] fix checkSemiAntiJoinTableAccess --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 38 +++++++++---------- ...913_semi_anti_join_column_access.reference | 2 + .../03913_semi_anti_join_column_access.sql | 11 ++++++ 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 226e9c8430f3..e2d05153b883 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -1680,7 +1680,12 @@ void QueryAnalyzer::updateMatchedColumnsFromJoinUsing( /** Check if a table expression is from the non-preserved side of a SEMI or ANTI JOIN. * Throws an exception if access is not allowed. - * Traverses the JOIN tree to find the relevant SEMI/ANTI JOIN node. + * + * We must check ALL SEMI/ANTI JOIN nodes on the path from the root to the table expression, + * not just the first one found. Consider: (t1 LEFT SEMI JOIN t2) LEFT SEMI JOIN t3 + * For t2.*, the outer join sees t2 on its left side (preserved) and would allow access, + * but the inner join sees t2 on its right side (non-preserved) and must deny access. + * Stopping at the first match would incorrectly allow t2.*. */ void checkSemiAntiJoinTableAccess( const QueryTreeNodePtr & table_expression_node, @@ -1694,7 +1699,8 @@ void checkSemiAntiJoinTableAccess( if (!query_node || !query_node->getJoinTree()) return; - /// Traverse the JOIN tree to find SEMI/ANTI JOIN nodes that contain the table expression + /// Follow the path from the root join down to the table expression, checking every + /// SEMI/ANTI JOIN node along the way. Access is denied if any containing join denies it. std::stack stack; stack.push(query_node->getJoinTree().get()); @@ -1707,22 +1713,15 @@ void checkSemiAntiJoinTableAccess( if (!join_node) continue; - /// Check if this is a SEMI or ANTI JOIN - if (join_node->getStrictness() != JoinStrictness::Semi && join_node->getStrictness() != JoinStrictness::Anti) - { - /// Not a SEMI/ANTI JOIN, continue traversing - stack.push(join_node->getLeftTableExpression().get()); - stack.push(join_node->getRightTableExpression().get()); - continue; - } - - /// Check if the table expression is from this JOIN bool is_from_left = isFromJoinTree(table_expression_node.get(), join_node->getLeftTableExpression().get()); - bool is_from_right = isFromJoinTree(table_expression_node.get(), join_node->getRightTableExpression().get()); + bool is_from_right = !is_from_left && isFromJoinTree(table_expression_node.get(), join_node->getRightTableExpression().get()); - if (is_from_left || is_from_right) + if (!is_from_left && !is_from_right) + continue; + + /// This join contains the table expression; check access if it is a SEMI/ANTI JOIN. + if (join_node->getStrictness() == JoinStrictness::Semi || join_node->getStrictness() == JoinStrictness::Anti) { - /// Found the relevant SEMI/ANTI JOIN, check access SemiAntiJoinSideChecker checker( *join_node, join_node->getStrictness(), @@ -1731,12 +1730,13 @@ void checkSemiAntiJoinTableAccess( scope.resolving_join_on_expression); JoinTableSide side = is_from_left ? JoinTableSide::Left : JoinTableSide::Right; checker.throwIfTableAccessDenied(side, *node_for_error_message, *scope.scope_node); - return; } - /// Continue traversing - stack.push(join_node->getLeftTableExpression().get()); - stack.push(join_node->getRightTableExpression().get()); + /// Descend only into the branch that actually contains the table expression. + if (is_from_left) + stack.push(join_node->getLeftTableExpression().get()); + else + stack.push(join_node->getRightTableExpression().get()); } } diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 5cf8ec5ed908..fa7da8afd702 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -15,3 +15,5 @@ 3 1 1 0 +1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index d00f7bef932d..7bd24d6a169b 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -101,3 +101,14 @@ SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON t1.a = t3.c; -- Test that inner SEMI JOIN's own ON expression can still access both sides SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON t1.a = t2.b LEFT JOIN (SELECT 3 AS c) t3 ON t1.a = t3.c; + +-- (t1 LEFT SEMI JOIN t2) LEFT SEMI JOIN t3: outer sees t2 on its preserved left side, but +-- the inner LEFT SEMI JOIN places t2 on its non-preserved right side -- must be denied. +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LEFT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } +-- t1 is on the preserved left of both joins -- must be allowed. +SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LEFT SEMI JOIN (SELECT 1 AS c) t3 ON true; +-- (t1 LEFT SEMI JOIN t2) RIGHT SEMI JOIN t3: outer RIGHT SEMI makes its entire left subtree +-- (including t1) non-preserved, regardless of the inner join's own preservation. +SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } +-- t3 is on the preserved right of the outer RIGHT SEMI JOIN -- must be allowed. +SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; From 4381c90308aec9b0de946c05f21587996767ea7d Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 19 Mar 2026 21:20:44 +0100 Subject: [PATCH 012/272] Move `get_zookeeper_lock_acquire_timeout_ms` setting from 26.3 to 26.4 in `SettingsChangesHistory.cpp` Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Core/SettingsChangesHistory.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index d1266a50553e..96517d5d91e7 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -39,6 +39,10 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() /// controls new feature and it's 'true' by default, use 'false' as previous_value). /// It's used to implement `compatibility` setting (see https://github.com/ClickHouse/ClickHouse/issues/35972) /// Note: please check if the key already exists to prevent duplicate entries. + addSettingsChanges(settings_changes_history, "26.4", + { + {"get_zookeeper_lock_acquire_timeout_ms", 0, DBMS_DEFAULT_LOCK_ACQUIRE_TIMEOUT_SEC * 1000, "New setting"}, + }); addSettingsChanges(settings_changes_history, "26.3", { {"allow_experimental_polyglot_dialect", false, false, "New setting to enable the polyglot SQL transpiler dialect."}, @@ -60,7 +64,6 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"webassembly_udf_max_instances", 32, 32, "New setting to limit the number of parallel WebAssembly UDF instances per function."}, {"mysql_datatypes_support_level", "", "decimal,datetime64,date2Date32", "Enable modern MySQL type mappings by default."}, {"allow_experimental_json_lazy_type_hints", false, false, "New experimental setting for lazy JSON type hints"}, - {"get_zookeeper_lock_acquire_timeout_ms", 0, DBMS_DEFAULT_LOCK_ACQUIRE_TIMEOUT_SEC * 1000, "New setting"}, {"allow_statistics", false, true, "Column statistics are now GA"}, {"allow_experimental_statistics", false, true, "Column statistics are now GA"}, {"allow_experimental_expire_snapshots", false, false, "New setting."}, From 5aafacdfd1964931a1893ebc74667cc6a6920010 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Wed, 25 Mar 2026 16:49:07 +0800 Subject: [PATCH 013/272] fix comments --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 3 ++- src/Core/SettingsChangesHistory.cpp | 4 ++-- .../03913_semi_anti_join_column_access.reference | 1 + .../0_stateless/03913_semi_anti_join_column_access.sql | 6 ++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 8d053cf0f267..e9eca552d016 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -4769,8 +4769,9 @@ void QueryAnalyzer::resolveJoin(QueryTreeNodePtr & join_node, IdentifierResolveS auto join_expression = join_node_typed.getJoinExpression(); /// Set pointer to current JOIN node to allow access to both sides in its ON expression + const auto * previous_resolving_join_on_expression = scope.resolving_join_on_expression; scope.resolving_join_on_expression = join_node.get(); - SCOPE_EXIT(scope.resolving_join_on_expression = nullptr); + SCOPE_EXIT(scope.resolving_join_on_expression = previous_resolving_join_on_expression); resolveExpressionNode(join_expression, scope, false /*allow_lambda_expression*/, false /*allow_table_expression*/); join_node_typed.getJoinExpression() = std::move(join_expression); diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 3fdd5d40cb6a..3f1a43a2504a 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -67,8 +67,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"webassembly_udf_max_instances", 32, 32, "New setting to limit the number of parallel WebAssembly UDF instances per function."}, {"mysql_datatypes_support_level", "", "decimal,datetime64,date2Date32", "Enable modern MySQL type mappings by default."}, {"allow_experimental_json_lazy_type_hints", false, false, "New experimental setting for lazy JSON type hints"}, - {"semi_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for SEMI JOINs"}, - {"anti_join_compatibility", false, false, "New setting to skip non-preserved side columns in SELECT * for ANTI JOINs"}, + {"semi_join_compatibility", false, false, "New setting to restrict SEMI JOIN column access to the preserved side except in JOIN ON expressions"}, + {"anti_join_compatibility", false, false, "New setting to restrict ANTI JOIN column access to the preserved side except in JOIN ON expressions"}, {"allow_statistics", false, true, "Column statistics are now GA"}, {"allow_experimental_statistics", false, true, "Column statistics are now GA"}, {"allow_experimental_expire_snapshots", false, false, "New setting."}, diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index fa7da8afd702..e744d9cc8ba4 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -17,3 +17,4 @@ 1 0 1 1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 7bd24d6a169b..a19052a1695a 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -101,6 +101,12 @@ SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON t1.a = t3.c; -- Test that inner SEMI JOIN's own ON expression can still access both sides SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON t1.a = t2.b LEFT JOIN (SELECT 3 AS c) t3 ON t1.a = t3.c; +-- Nested `JOIN` resolution inside a scalar subquery in outer `ON` must not lose outer `JOIN` context +SELECT * +FROM (SELECT 1 AS a) t1 +LEFT SEMI JOIN (SELECT 1 AS b) t2 + ON (SELECT count() FROM (SELECT 1 AS c) t3 INNER JOIN (SELECT 1 AS d) t4 ON t3.c = t4.d) > 0 + AND t1.a = t2.b; -- (t1 LEFT SEMI JOIN t2) LEFT SEMI JOIN t3: outer sees t2 on its preserved left side, but -- the inner LEFT SEMI JOIN places t2 on its non-preserved right side -- must be denied. From 7bec7054858d9bcd9831ff779ed635af32706b14 Mon Sep 17 00:00:00 2001 From: Christian Endres Date: Thu, 26 Mar 2026 08:12:00 +0000 Subject: [PATCH 014/272] address review comments --- src/Core/Settings.cpp | 2 +- src/Interpreters/Context.cpp | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index f3c168ececc1..ef84abe47337 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -4704,7 +4704,7 @@ Possible values: - 0 — No locking timeout. )", 0) \ DECLARE(Milliseconds, get_zookeeper_lock_acquire_timeout_ms, DBMS_DEFAULT_LOCK_ACQUIRE_TIMEOUT_SEC * 1000, R"( -Defines how many milliseconds a locking request in Context::getZooKeeper should wait before failing. +Defines how many milliseconds a locking request in Context::getZooKeeper and Context::getAuxiliaryZooKeeper should wait before failing. Possible values: diff --git a/src/Interpreters/Context.cpp b/src/Interpreters/Context.cpp index 44be722eb3fb..3c5dc02e5172 100644 --- a/src/Interpreters/Context.cpp +++ b/src/Interpreters/Context.cpp @@ -4900,7 +4900,9 @@ zkutil::ZooKeeperPtr Context::getZooKeeper() const if (hasQueryContext()) lock_acquire_timeout = getQueryContext()->getSettingsRef()[Setting::get_zookeeper_lock_acquire_timeout_ms]; std::unique_lock lock(shared->zookeeper_mutex, std::defer_lock); - if (!lock.try_lock_for(std::chrono::milliseconds(lock_acquire_timeout.totalMilliseconds()))) + if (lock_acquire_timeout.totalMilliseconds() == 0) + lock.lock(); + else if (!lock.try_lock_for(std::chrono::milliseconds(lock_acquire_timeout.totalMilliseconds()))) throw Exception(ErrorCodes::TIMEOUT_EXCEEDED, "Timeout exceeded while acquiring ZooKeeper lock ({} ms)", lock_acquire_timeout.totalMilliseconds()); const auto & config = shared->zookeeper_config ? *shared->zookeeper_config : getConfigRef(); @@ -5177,7 +5179,9 @@ zkutil::ZooKeeperPtr Context::getAuxiliaryZooKeeper(const String & name) const if (hasQueryContext()) lock_acquire_timeout = getQueryContext()->getSettingsRef()[Setting::get_zookeeper_lock_acquire_timeout_ms]; std::unique_lock lock(shared->auxiliary_zookeepers_mutex, std::defer_lock); - if (!lock.try_lock_for(std::chrono::milliseconds(lock_acquire_timeout.totalMilliseconds()))) + if (lock_acquire_timeout.totalMilliseconds() == 0) + lock.lock(); + else if (!lock.try_lock_for(std::chrono::milliseconds(lock_acquire_timeout.totalMilliseconds()))) throw Exception(ErrorCodes::TIMEOUT_EXCEEDED, "Timeout exceeded while acquiring auxiliary ZooKeeper lock ({} ms)", lock_acquire_timeout.totalMilliseconds()); const auto config_name = "auxiliary_zookeepers." + name; From 5f00988336a0a089c7dff8e0b6232408c86721a6 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Thu, 26 Mar 2026 17:25:22 +0800 Subject: [PATCH 015/272] update docs and add more tests --- .../sql-reference/statements/select/join.md | 11 ++++++++ src/Core/Settings.cpp | 18 ++++++------ ...913_semi_anti_join_column_access.reference | 3 ++ .../03913_semi_anti_join_column_access.sql | 28 +++++++++++++++++-- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/en/sql-reference/statements/select/join.md b/docs/en/sql-reference/statements/select/join.md index 4479750c8e76..cf9356294bea 100644 --- a/docs/en/sql-reference/statements/select/join.md +++ b/docs/en/sql-reference/statements/select/join.md @@ -50,6 +50,17 @@ Additional join types available in ClickHouse are: | `ASOF JOIN`, `LEFT ASOF JOIN` | Joining sequences with a non-exact match. `ASOF JOIN` usage is described below. | | `PASTE JOIN` | Performs a horizontal concatenation of two tables. | +:::note +With the new analyzer, [`semi_join_compatibility`](../../../operations/settings/settings.md#semi_join_compatibility) and [`anti_join_compatibility`](../../../operations/settings/settings.md#anti_join_compatibility) make `SEMI` and `ANTI` joins expose only their preserved side to expressions resolved after the join result is formed. + +- `LEFT SEMI JOIN` and `LEFT ANTI JOIN` expose only left-side columns. +- `RIGHT SEMI JOIN` and `RIGHT ANTI JOIN` expose only right-side columns. +- This affects clauses such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY`, including qualified matchers like `t1.*`. +- The `ON` expression of the same `JOIN` can still reference both sides. + +When these settings are disabled, ClickHouse keeps the legacy behavior, where both sides remain accessible and `SELECT *` expands columns from both tables. +::: + :::note When [join_algorithm](../../../operations/settings/settings.md#join_algorithm) is set to `partial_merge`, `RIGHT JOIN` and `FULL JOIN` are supported only with `ALL` strictness (`SEMI`, `ANTI`, `ANY`, and `ASOF` are not supported). ::: diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index 0c724b25264f..8bf148deed60 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7170,18 +7170,20 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. )", 0) \ DECLARE(Bool, semi_join_compatibility, false, R"( -When enabled, `SEMI` JOIN restricts column access to the preserved side per SQL standard. -For `LEFT SEMI JOIN` only left table columns are accessible in `SELECT` and `WHERE` clauses (including qualified matchers like `t1.*`), for `RIGHT SEMI JOIN` only right table columns. +When enabled with the new analyzer, `SEMI` JOIN restricts column access to the preserved side per SQL standard. +For `LEFT SEMI JOIN` only left table columns are accessible, for `RIGHT SEMI JOIN` only right table columns. +This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified matchers like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. -`JOIN ON` expressions can access both sides regardless of this setting. -When disabled, `SELECT *` returns columns from both sides (legacy behavior). +The `JOIN ON` expression of the same `JOIN` can access both sides regardless of this setting. +When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. )", 0) \ DECLARE(Bool, anti_join_compatibility, false, R"( -When enabled, `ANTI` JOIN restricts column access to the preserved side per SQL standard. -For `LEFT ANTI JOIN` only left table columns are accessible in `SELECT` and `WHERE` clauses (including qualified matchers like `t1.*`), for `RIGHT ANTI JOIN` only right table columns. +When enabled with the new analyzer, `ANTI` JOIN restricts column access to the preserved side per SQL standard. +For `LEFT ANTI JOIN` only left table columns are accessible, for `RIGHT ANTI JOIN` only right table columns. +This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified matchers like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. -`JOIN ON` expressions can access both sides regardless of this setting. -When disabled, `SELECT *` returns columns from both sides (legacy behavior). +The `JOIN ON` expression of the same `JOIN` can access both sides regardless of this setting. +When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. )", 0) \ DECLARE(Timezone, session_timezone, "", R"( Sets the implicit time zone of the current session or query. diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index e744d9cc8ba4..2e55fd060fb8 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -18,3 +18,6 @@ 1 1 1 +1 +1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index a19052a1695a..b9251c5a5739 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -1,8 +1,10 @@ -- Test SEMI/ANTI JOIN column access restrictions with SQL standard semantics. -- When semi_join_compatibility or anti_join_compatibility settings are enabled, --- only columns from the preserved side are accessible in: +-- only columns from the preserved side are accessible in expressions resolved +-- from the joined result, including: -- 1. SELECT clause (SELECT *, qualified matchers like t2.*) --- 2. WHERE clause +-- 2. PREWHERE and WHERE clauses +-- 3. HAVING and ORDER BY clauses -- -- Preserved side: -- - LEFT SEMI/ANTI JOIN: left side preserved, right side not accessible @@ -118,3 +120,25 @@ SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LE SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } -- t3 is on the preserved right of the outer RIGHT SEMI JOIN -- must be allowed. SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; + +-- Additional clause coverage: PREWHERE, HAVING, and ORDER BY + +-- 'PREWHERE'; +DROP TABLE IF EXISTS semi_anti_prewhere_left; +DROP TABLE IF EXISTS semi_anti_prewhere_right; +CREATE TABLE semi_anti_prewhere_left (number UInt64) ENGINE = MergeTree ORDER BY number; +CREATE TABLE semi_anti_prewhere_right (number UInt64) ENGINE = MergeTree ORDER BY number; +INSERT INTO semi_anti_prewhere_left VALUES (1); +INSERT INTO semi_anti_prewhere_right VALUES (1); +SELECT * FROM semi_anti_prewhere_left AS t1 LEFT SEMI JOIN semi_anti_prewhere_right AS t2 ON true PREWHERE t2.number = 1; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM semi_anti_prewhere_left AS t1 LEFT SEMI JOIN semi_anti_prewhere_right AS t2 ON true PREWHERE t1.number = 1; +DROP TABLE semi_anti_prewhere_left; +DROP TABLE semi_anti_prewhere_right; + +-- 'HAVING'; +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a HAVING t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a HAVING t1.a = 1; + +-- 'ORDER BY'; +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ORDER BY t2.b; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ORDER BY t1.a; From 01b5b100447a7aefa0c084efb31e4893f466566c Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Thu, 26 Mar 2026 18:16:46 +0800 Subject: [PATCH 016/272] fix checkstyle error --- docs/en/sql-reference/statements/select/join.md | 2 +- src/Core/Settings.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/en/sql-reference/statements/select/join.md b/docs/en/sql-reference/statements/select/join.md index cf9356294bea..8b425ac0b97c 100644 --- a/docs/en/sql-reference/statements/select/join.md +++ b/docs/en/sql-reference/statements/select/join.md @@ -55,7 +55,7 @@ With the new analyzer, [`semi_join_compatibility`](../../../operations/settings/ - `LEFT SEMI JOIN` and `LEFT ANTI JOIN` expose only left-side columns. - `RIGHT SEMI JOIN` and `RIGHT ANTI JOIN` expose only right-side columns. -- This affects clauses such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY`, including qualified matchers like `t1.*`. +- This affects clauses such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY`, including qualified wildcards like `t1.*`. - The `ON` expression of the same `JOIN` can still reference both sides. When these settings are disabled, ClickHouse keeps the legacy behavior, where both sides remain accessible and `SELECT *` expands columns from both tables. diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index 8bf148deed60..79605e6f9630 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7172,7 +7172,7 @@ Allow to add compound identifiers to nested. This is a compatibility setting bec DECLARE(Bool, semi_join_compatibility, false, R"( When enabled with the new analyzer, `SEMI` JOIN restricts column access to the preserved side per SQL standard. For `LEFT SEMI JOIN` only left table columns are accessible, for `RIGHT SEMI JOIN` only right table columns. -This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified matchers like `t1.*`. +This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. The `JOIN ON` expression of the same `JOIN` can access both sides regardless of this setting. When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. @@ -7180,7 +7180,7 @@ When disabled, legacy behavior keeps columns from both sides accessible, and `SE DECLARE(Bool, anti_join_compatibility, false, R"( When enabled with the new analyzer, `ANTI` JOIN restricts column access to the preserved side per SQL standard. For `LEFT ANTI JOIN` only left table columns are accessible, for `RIGHT ANTI JOIN` only right table columns. -This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified matchers like `t1.*`. +This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. The `JOIN ON` expression of the same `JOIN` can access both sides regardless of this setting. When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. From 7ad9226dcfdb5e05e3da5b617e1a0fbdeba45861 Mon Sep 17 00:00:00 2001 From: Christian Endres Date: Thu, 26 Mar 2026 10:54:13 +0000 Subject: [PATCH 017/272] Remove time.sleep --- .../integration/test_zookeeper_lock_acquire_timeout/test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_zookeeper_lock_acquire_timeout/test.py b/tests/integration/test_zookeeper_lock_acquire_timeout/test.py index cc3b6623140e..a38db5cef735 100644 --- a/tests/integration/test_zookeeper_lock_acquire_timeout/test.py +++ b/tests/integration/test_zookeeper_lock_acquire_timeout/test.py @@ -10,6 +10,7 @@ import pytest import time import concurrent.futures from helpers.cluster import ClickHouseCluster, QueryRuntimeException +from helpers.test_tools import assert_eq_with_retry cluster = ClickHouseCluster(__file__, zookeeper_config_path="configs/zookeeper.xml") @@ -80,8 +81,8 @@ def test_zookeeper_lock_acquire_timeout(started_cluster, zk_name): # Start the long-timeout query first long_future = executor.submit(run_query, "long", long_timeout_ms) - # Give it a moment to acquire the mutex - time.sleep(1) + # Long-timeout query should be running and acquire the mutex + assert_eq_with_retry(node, "SELECT count() FROM system.processes WHERE query_id = 'long'", "1") # Now fire short-timeout queries short_futures = [executor.submit(run_query, f"short_{i}", short_timeout_ms) for i in range(num_short_queries)] From 8e6fb4e0a0787662c302688d7f10710553f74e80 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Wed, 8 Apr 2026 16:48:46 +0800 Subject: [PATCH 018/272] fix comments and move new settings to 26.4 --- src/Analyzer/Resolve/IdentifierResolver.cpp | 3 +-- src/Core/SettingsChangesHistory.cpp | 4 ++-- ...03913_semi_anti_join_column_access.reference | 3 +++ .../03913_semi_anti_join_column_access.sql | 17 +++++++++++++++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index a07d15eb78a6..430f0a371a64 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -33,7 +33,6 @@ #include #include #include -#include #include @@ -1031,7 +1030,7 @@ void SemiAntiJoinSideChecker::throwIfTableAccessDenied( const char * join_type_str = is_semi ? "SEMI" : "ANTI"; const char * side_str = skip_right ? "right" : "left"; throw Exception(ErrorCodes::UNKNOWN_IDENTIFIER, - "Cannot access columns from the {} side of {} JOIN using qualified matcher {}. In scope {}", + "Cannot access columns from the {} side of {} JOIN in this context. Expression {} is not available. In scope {}", side_str, join_type_str, node_for_error_message.formatASTForErrorMessage(), diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 7d21adbe9bc4..bce57d19ad9e 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -54,6 +54,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"analyzer_inline_views", false, false, "New setting"}, {"highlight_max_matches_per_row", 10000, 10000, "New setting to limit the number of highlight matches per row to protect against excessive memory usage."}, {"enable_join_transitive_predicates", false, false, "New setting to infer transitive equi-join predicates for join order optimization."}, + {"semi_join_compatibility", false, false, "New setting to restrict SEMI JOIN column access to the preserved side except in JOIN ON expressions"}, + {"anti_join_compatibility", false, false, "New setting to restrict ANTI JOIN column access to the preserved side except in JOIN ON expressions"}, }); addSettingsChanges(settings_changes_history, "26.3", { @@ -77,8 +79,6 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"webassembly_udf_max_instances", 32, 32, "New setting to limit the number of parallel WebAssembly UDF instances per function."}, {"mysql_datatypes_support_level", "", "decimal,datetime64,date2Date32", "Enable modern MySQL type mappings by default."}, {"allow_experimental_json_lazy_type_hints", false, false, "New experimental setting for lazy JSON type hints"}, - {"semi_join_compatibility", false, false, "New setting to restrict SEMI JOIN column access to the preserved side except in JOIN ON expressions"}, - {"anti_join_compatibility", false, false, "New setting to restrict ANTI JOIN column access to the preserved side except in JOIN ON expressions"}, {"allow_statistics", false, true, "Column statistics are now GA"}, {"allow_experimental_statistics", false, true, "Column statistics are now GA"}, {"allow_experimental_expire_snapshots", false, false, "New setting."}, diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 2e55fd060fb8..1b60a3219d29 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -21,3 +21,6 @@ 1 1 1 +1 +1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index b9251c5a5739..43f220e97e92 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -4,7 +4,8 @@ -- from the joined result, including: -- 1. SELECT clause (SELECT *, qualified matchers like t2.*) -- 2. PREWHERE and WHERE clauses --- 3. HAVING and ORDER BY clauses +-- 3. GROUP BY, HAVING, and QUALIFY clauses +-- 4. LIMIT BY and ORDER BY clauses -- -- Preserved side: -- - LEFT SEMI/ANTI JOIN: left side preserved, right side not accessible @@ -121,7 +122,7 @@ SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RI -- t3 is on the preserved right of the outer RIGHT SEMI JOIN -- must be allowed. SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; --- Additional clause coverage: PREWHERE, HAVING, and ORDER BY +-- Additional clause coverage: PREWHERE, GROUP BY, HAVING, QUALIFY, LIMIT BY, and ORDER BY -- 'PREWHERE'; DROP TABLE IF EXISTS semi_anti_prewhere_left; @@ -139,6 +140,18 @@ DROP TABLE semi_anti_prewhere_right; SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a HAVING t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a HAVING t1.a = 1; +-- 'GROUP BY'; +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t2.b; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a; + +-- 'QUALIFY'; +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true QUALIFY row_number() OVER () = 1 AND t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true QUALIFY row_number() OVER () = 1 AND t1.a = 1; + +-- 'LIMIT BY'; +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false LIMIT 1 BY t2.b; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false LIMIT 1 BY t1.a; + -- 'ORDER BY'; SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ORDER BY t2.b; -- { serverError UNKNOWN_IDENTIFIER } SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ORDER BY t1.a; From 7d0fe858533de8cf48fb7ae8c189c87b482a1c3a Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Wed, 8 Apr 2026 18:09:17 +0800 Subject: [PATCH 019/272] Fix semi/anti table access validation to traverse CrossJoinNode subtrees --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 10 ++++++++++ .../03913_semi_anti_join_column_access.reference | 1 + .../0_stateless/03913_semi_anti_join_column_access.sql | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 9d933b0ef28c..a110873c5ed0 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -1767,6 +1767,16 @@ void checkSemiAntiJoinTableAccess( const auto * current = stack.top(); stack.pop(); + if (const auto * cross_join_node = const_cast(current)->as()) + { + for (const auto & table_expression : cross_join_node->getTableExpressions()) + { + if (isFromJoinTree(table_expression_node.get(), table_expression.get())) + stack.push(table_expression.get()); + } + continue; + } + const auto * join_node = const_cast(current)->as(); if (!join_node) continue; diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 1b60a3219d29..931a1d0c2e19 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -24,3 +24,4 @@ 1 1 1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 43f220e97e92..2d35b0714fc0 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -121,6 +121,11 @@ SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LE SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } -- t3 is on the preserved right of the outer RIGHT SEMI JOIN -- must be allowed. SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; +-- (t1 LEFT SEMI JOIN t2) CROSS JOIN t3: t2 remains non-preserved due to the inner LEFT SEMI JOIN +-- and must stay inaccessible even when wrapped by CROSS JOIN at the root. +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true CROSS JOIN (SELECT 1 AS c) t3; -- { serverError UNKNOWN_IDENTIFIER } +-- t1 is preserved by the inner LEFT SEMI JOIN and should remain accessible. +SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true CROSS JOIN (SELECT 1 AS c) t3; -- Additional clause coverage: PREWHERE, GROUP BY, HAVING, QUALIFY, LIMIT BY, and ORDER BY From e4473049163d887192681294eeb6569d0eed5574 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Thu, 9 Apr 2026 19:39:51 +0800 Subject: [PATCH 020/272] reuse SemiAntiJoinSideChecker --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 23 ++++++++----------- .../0_stateless/02995_settings_26_3_1.tsv | 2 -- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index a110873c5ed0..4047b72b9690 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -77,8 +77,6 @@ namespace Setting extern const SettingsBool asterisk_include_materialized_columns; extern const SettingsString count_distinct_implementation; extern const SettingsBool enable_global_with_statement; - extern const SettingsBool semi_join_compatibility; - extern const SettingsBool anti_join_compatibility; extern const SettingsBool enable_materialized_cte; extern const SettingsBool enable_scopes_for_with_statement; extern const SettingsBool enable_order_by_all; @@ -2127,19 +2125,16 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( /** For SEMI/ANTI JOIN, SELECT * should only return columns from one side per SQL standard: * - LEFT SEMI/ANTI JOIN: only left table columns * - RIGHT SEMI/ANTI JOIN: only right table columns - * Controlled by `semi_join_compatibility` - * and `anti_join_compatibility` settings. + * Controlled by `semi_join_compatibility` and `anti_join_compatibility` (see `SemiAntiJoinSideChecker`). */ - bool is_semi = join_node->getStrictness() == JoinStrictness::Semi; - bool is_anti = join_node->getStrictness() == JoinStrictness::Anti; - const auto & settings = scope.context->getSettingsRef(); - bool skip_non_preserved_side = - (is_semi && settings[Setting::semi_join_compatibility]) - || (is_anti && settings[Setting::anti_join_compatibility]); - bool skip_left = skip_non_preserved_side && isRight(join_node->getKind()); - bool skip_right = skip_non_preserved_side && isLeft(join_node->getKind()); + SemiAntiJoinSideChecker semi_anti_star_checker( + *join_node, + join_node->getStrictness(), + join_node->getKind(), + scope.context, + scope.resolving_join_on_expression); - if (!skip_left) + if (!semi_anti_star_checker.shouldSkipSide(JoinTableSide::Left)) { for (auto && left_table_column_with_name : left_table_expression_columns) { @@ -2150,7 +2145,7 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( } } - if (!skip_right) + if (!semi_anti_star_checker.shouldSkipSide(JoinTableSide::Right)) { for (auto && right_table_column_with_name : right_table_expression_columns) { diff --git a/tests/queries/0_stateless/02995_settings_26_3_1.tsv b/tests/queries/0_stateless/02995_settings_26_3_1.tsv index 284ed04939cb..040d7cbac627 100644 --- a/tests/queries/0_stateless/02995_settings_26_3_1.tsv +++ b/tests/queries/0_stateless/02995_settings_26_3_1.tsv @@ -1396,8 +1396,6 @@ schema_inference_use_cache_for_s3 1 schema_inference_use_cache_for_url 1 secondary_indices_enable_bulk_filtering 1 select_sequential_consistency 0 -anti_join_compatibility 0 -semi_join_compatibility 0 send_logs_level fatal send_logs_source_regexp send_profile_events 1 From 73ff0168dfbc4cb46cb7119fa90a27a509e7912a Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Fri, 8 May 2026 12:24:13 +0800 Subject: [PATCH 021/272] move new settings to 26.5 --- src/Core/SettingsChangesHistory.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 827389b47f3c..60b00e55317c 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -61,6 +61,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"send_table_structure_on_insert_with_inline_data", true, true, "New setting to control whether server sends table structure for INSERT queries with inline data."}, {"use_top_k_dynamic_filtering_for_variable_length_types", true, false, "Disable `use_top_k_dynamic_filtering` for variable-length sort columns (e.g. `String`) by default; the previous behavior had the optimization apply unconditionally and is preserved under `compatibility`."}, {"page_cache_max_coalesced_bytes", 16777216, 16777216, "New setting to bound the size of a single coalesced read used to populate the userspace page cache on cache miss."}, + {"semi_join_compatibility", false, false, "New setting to restrict `SEMI JOIN` column access to the preserved side except in `JOIN ON` expressions"}, + {"anti_join_compatibility", false, false, "New setting to restrict `ANTI JOIN` column access to the preserved side except in `JOIN ON` expressions"}, }); addSettingsChanges(settings_changes_history, "26.4", { @@ -100,8 +102,6 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"highlight_max_matches_per_row", 10000, 10000, "New setting to limit the number of highlight matches per row to protect against excessive memory usage."}, {"materialize_statistics_on_insert", true, false, "Disable building statistics on INSERT by default, rely on merges instead"}, {"enable_join_transitive_predicates", false, false, "New setting to infer transitive equi-join predicates for join order optimization."}, - {"semi_join_compatibility", false, false, "New setting to restrict SEMI JOIN column access to the preserved side except in JOIN ON expressions"}, - {"anti_join_compatibility", false, false, "New setting to restrict ANTI JOIN column access to the preserved side except in JOIN ON expressions"}, {"input_format_column_name_matching_mode", "match_case", "match_case", "New setting."}, {"enable_join_fixed_hash_table_conversion", false, true, "New setting to enable converting the hash table to a flat array for joins when the key is a single integer with a small value range."}, {"allow_experimental_ai_functions", false, false, "New setting"}, From f4f0e53bb4c61b4cf3f841fa2482868415b575e0 Mon Sep 17 00:00:00 2001 From: adityaksolves Date: Sun, 21 Jun 2026 12:23:01 +0530 Subject: [PATCH 022/272] Fix parseDatabaseAndTableNameOrAsterisks: reject db*.table as invalid syntax db*.table was silently converted to db.table* because the wildcard flag set for database asterisk (db*) was not reset when a specific table name followed. Track database wildcard separately and reject db*.table pattern as invalid/ambiguous syntax. Fixes #104864 --- src/Parsers/parseDatabaseAndTableName.cpp | 25 ++++++++--- ...2_wildcard_grant_db_table_syntax.reference | 6 +++ .../04402_wildcard_grant_db_table_syntax.sql | 42 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.reference create mode 100644 tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql diff --git a/src/Parsers/parseDatabaseAndTableName.cpp b/src/Parsers/parseDatabaseAndTableName.cpp index 66a5d91a48fa..ceb2713458b7 100644 --- a/src/Parsers/parseDatabaseAndTableName.cpp +++ b/src/Parsers/parseDatabaseAndTableName.cpp @@ -96,8 +96,9 @@ bool parseDatabaseAndTableNameOrAsterisks(IParser::Pos & pos, Expected & expecte if (identifier_parser.parse(pos, ast, expected)) { String first_identifier = getIdentifierName(ast); + bool database_has_wildcard = false; if (ParserToken{TokenType::Asterisk}.ignore(pos, expected)) - wildcard = true; + database_has_wildcard = true; auto pos_before_dot = pos; @@ -105,14 +106,23 @@ bool parseDatabaseAndTableNameOrAsterisks(IParser::Pos & pos, Expected & expecte { if (ParserToken{TokenType::Asterisk}.ignore(pos, expected)) { - /// db.* + /// db.* or db*.* — both are valid + /// Note: wildcard is NOT set here; db.* uses empty table name + /// to represent "all tables", consistent with original behavior. database = std::move(first_identifier); table.clear(); + wildcard = database_has_wildcard; return true; } + if (database_has_wildcard) + { + /// db*.table or db*.table* — invalid, ambiguous syntax. + /// A database wildcard cannot be combined with a specific table name. + return false; + } if (identifier_parser.parse(pos, ast, expected)) { - /// db.table + /// db.table or db.table* database = std::move(first_identifier); table = getIdentifierName(ast); if (ParserToken{TokenType::Asterisk}.ignore(pos, expected)) @@ -122,13 +132,18 @@ bool parseDatabaseAndTableNameOrAsterisks(IParser::Pos & pos, Expected & expecte } } - /// table + /// table or table* (no dot found) pos = pos_before_dot; database.clear(); table = std::move(first_identifier); default_database = true; - if (!wildcard && ParserToken{TokenType::Asterisk}.ignore(pos, expected)) + /// If asterisk was already consumed as part of "table*" pattern, + /// carry it forward as table wildcard. + /// e.g. "foo*" → table="foo", wildcard=true + if (database_has_wildcard) + wildcard = true; + else if (ParserToken{TokenType::Asterisk}.ignore(pos, expected)) wildcard = true; return true; diff --git a/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.reference b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.reference new file mode 100644 index 000000000000..d5318d75f713 --- /dev/null +++ b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.reference @@ -0,0 +1,6 @@ +GRANT SELECT ON db.* TO user_04402 +GRANT SELECT ON db.* TO user_04402 +GRANT SELECT ON mydb*.* TO user_04402 +GRANT SELECT ON db.* TO user_04402 +GRANT SELECT ON mydb*.* TO user_04402 +GRANT SELECT ON *.* TO user_04402 diff --git a/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql new file mode 100644 index 000000000000..6e3d45d8ad9f --- /dev/null +++ b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql @@ -0,0 +1,42 @@ +-- Tags: no-parallel +-- Regression test for https://github.com/ClickHouse/ClickHouse/issues/104864 +-- parseDatabaseAndTableNameOrAsterisks silently converted db*.table into db.table* + +-- ============================================================ +-- PART 1: Valid wildcard patterns -- must still work correctly +-- ============================================================ + +DROP USER IF EXISTS user_04402; +CREATE USER user_04402; + +-- Valid: specific db, all tables +GRANT SELECT ON db.* TO user_04402; +SHOW GRANTS FOR user_04402; + +-- Valid: db prefix wildcard, all tables +GRANT SELECT ON mydb*.* TO user_04402; +SHOW GRANTS FOR user_04402; + +-- Valid: specific db, table prefix wildcard +GRANT SELECT ON db.mytable* TO user_04402; +SHOW GRANTS FOR user_04402; + +-- Valid: all dbs, all tables +GRANT SELECT ON *.* TO user_04402; +SHOW GRANTS FOR user_04402; + +DROP USER user_04402; + +-- ============================================================ +-- PART 2: Invalid patterns -- must produce SYNTAX_ERROR +-- ============================================================ + +-- BUG WAS HERE: db*.table was silently interpreted as db.table* +-- Now it must be rejected as invalid/ambiguous syntax +GRANT SELECT ON db*.table TO user_04402; -- { clientError SYNTAX_ERROR } + +-- db wildcard + specific table name = also invalid +GRANT SELECT ON db*.mytable TO user_04402; -- { clientError SYNTAX_ERROR } + +-- db wildcard + table wildcard combined = also invalid +GRANT SELECT ON mydb*.mytable* TO user_04402; -- { clientError SYNTAX_ERROR } From b29b2a78a6e7c0379bcd0d623a74f7b221e00888 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 3 Jul 2026 05:22:00 +0000 Subject: [PATCH 023/272] Fix build: make checkSemiAntiJoinTableAccess static The free function `checkSemiAntiJoinTableAccess` in `QueryAnalyzer.cpp` was defined at namespace scope without a prototype, which fails to compile under `-Werror,-Wmissing-prototypes` (and the equivalent clang-tidy `clang-diagnostic-missing-prototypes` in the tidy build): QueryAnalyzer.cpp:1930:6: error: no previous prototype for function 'checkSemiAntiJoinTableAccess' [-Werror,-Wmissing-prototypes] The function is only defined and used within this translation unit, so mark it `static` as the compiler itself suggests. This regression was introduced by the previous merge of `master`, which was not built locally. Both `Fast test -> Build ClickHouse` and `Build (arm_tidy) -> Build ClickHouse` failed on this single error. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=96662&sha=5aa319c69536d0978c688990510e6e7385244cf6&name_0=PR&name_1=Fast%20test Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 74a6098e6953..01a5d56875d6 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -1927,7 +1927,7 @@ void QueryAnalyzer::updateMatchedColumnsFromJoinUsing( * but the inner join sees t2 on its right side (non-preserved) and must deny access. * Stopping at the first match would incorrectly allow t2.*. */ -void checkSemiAntiJoinTableAccess( +static void checkSemiAntiJoinTableAccess( const QueryTreeNodePtr & table_expression_node, const IdentifierResolveScope & scope, const QueryTreeNodePtr & node_for_error_message) From 860405a62a8e070cec3f36b35359636e87234c2d Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Fri, 3 Jul 2026 17:50:45 +0800 Subject: [PATCH 024/272] Inherit JOIN ON marker into child scopes for SEMI/ANTI JOIN `resolving_join_on_expression` was only set on the immediate scope of the `JOIN` `ON` expression. `resolveLambda` (and other constructs) create child `IdentifierResolveScope`s whose marker stayed `nullptr`, so a matcher or column resolved inside a lambda body of the same `ON` clause was treated as post-join access and the non-preserved side was rejected. For example `... LEFT SEMI JOIN t2 ON arrayExists(x -> ignore(t2.*) = 0, [1])` raised `UNKNOWN_IDENTIFIER` even though the `ON` clause may reference both sides. Inherit the marker from the parent scope so nested scopes keep the same both-sides exemption as the enclosing `ON`. The exemption still does not leak past the `ON` clause (e.g. the same lambda in `WHERE` is still rejected). Co-authored-by: Cursor --- src/Analyzer/Resolve/IdentifierResolveScope.cpp | 3 +++ .../03913_semi_anti_join_column_access.reference | 2 ++ .../0_stateless/03913_semi_anti_join_column_access.sql | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/src/Analyzer/Resolve/IdentifierResolveScope.cpp b/src/Analyzer/Resolve/IdentifierResolveScope.cpp index b161dcc69d4c..b46f3bf63088 100644 --- a/src/Analyzer/Resolve/IdentifierResolveScope.cpp +++ b/src/Analyzer/Resolve/IdentifierResolveScope.cpp @@ -29,6 +29,9 @@ IdentifierResolveScope::IdentifierResolveScope(QueryTreeNodePtr scope_node_, Ide context = parent_scope->context; projection_mask_map = parent_scope->projection_mask_map; global_with_aliases = parent_scope->global_with_aliases; + /// Inherit the JOIN ON marker so nested scopes (e.g. lambda bodies) resolving a matcher or + /// column inside the same ON expression keep the both-sides exemption for SEMI/ANTI JOIN. + resolving_join_on_expression = parent_scope->resolving_join_on_expression; if (parent_scope->identifier_resolve_cache_force_disabled) disableIdentifierCachePermanently(); diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 931a1d0c2e19..0dd2b56d80ef 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -25,3 +25,5 @@ 1 1 1 +1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 2d35b0714fc0..cd13199b6efd 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -111,6 +111,13 @@ LEFT SEMI JOIN (SELECT 1 AS b) t2 ON (SELECT count() FROM (SELECT 1 AS c) t3 INNER JOIN (SELECT 1 AS d) t4 ON t3.c = t4.d) > 0 AND t1.a = t2.b; +-- A lambda body inside the JOIN ON expression is resolved in a child scope; that child scope must +-- inherit the ON-clause exemption, so a matcher/column from the non-preserved side stays accessible. +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON arrayExists(x -> ignore(t2.*) = 0, [1]); +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON arrayExists(x -> t2.b = 2, [1]); +-- The exemption must not leak past the ON clause: the same lambda in WHERE still cannot see the non-preserved side. +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE arrayExists(x -> ignore(t2.*) = 0, [1]); -- { serverError UNKNOWN_IDENTIFIER } + -- (t1 LEFT SEMI JOIN t2) LEFT SEMI JOIN t3: outer sees t2 on its preserved left side, but -- the inner LEFT SEMI JOIN places t2 on its non-preserved right side -- must be denied. SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LEFT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } From 987a3725bf0550f7cff06cec68c6e2d68130e874 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Mon, 6 Jul 2026 15:22:07 +0800 Subject: [PATCH 025/272] Descend through ARRAY JOIN when checking SEMI/ANTI JOIN column access `checkSemiAntiJoinTableAccess` and `isFromJoinTree` only walked `JOIN` and `CROSS JOIN` nodes, so a qualified matcher whose SEMI/ANTI JOIN was wrapped by `ARRAY JOIN` never reached the join and its non-preserved side was incorrectly accepted, e.g. `SELECT t2.* FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true ARRAY JOIN arr` returned `t2.*` even with `semi_join_compatibility = 1`. Introduce a single `collectJoinTreeChildTableExpressions` helper that enumerates the child table expressions of every join-tree internal node (`JOIN`, `CROSS JOIN`, `ARRAY JOIN`) and reuse it from both traversals, so a `SEMI`/`ANTI` JOIN nested below any of them is still reached and checked. This removes the duplicated, partial node-type enumeration that kept leaving holes. Co-authored-by: Cursor --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 97 +++++++++++-------- ...913_semi_anti_join_column_access.reference | 1 + .../03913_semi_anti_join_column_access.sql | 8 ++ 3 files changed, 66 insertions(+), 40 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 01a5d56875d6..510cff4a88d8 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -356,6 +356,31 @@ void QueryAnalyzer::resolveConstantExpression(QueryTreeNodePtr & node, const Que validateCorrelatedSubqueries(node); } +/** Append the immediate child table expressions of a join-tree internal node to `children`. + * Internal nodes are `JOIN` (left, right), `CROSS JOIN` (all operands) and `ARRAY JOIN` (its + * wrapped table expression). Leaf table expressions (table, table function, subquery, ...) add + * nothing. This is the single place that enumerates join-tree node types, so that every traversal + * (subtree membership, SEMI/ANTI side checks, ...) stays consistent when a new node type appears. + * Keep in sync with the join-tree node types handled by `initializeQueryJoinTreeNode`. + */ +static void collectJoinTreeChildTableExpressions(const IQueryTreeNode * node, std::vector & children) +{ + if (const auto * join_node = node->as()) + { + children.push_back(join_node->getLeftTableExpression().get()); + children.push_back(join_node->getRightTableExpression().get()); + } + else if (const auto * cross_join_node = node->as()) + { + for (const auto & table_expression : cross_join_node->getTableExpressions()) + children.push_back(table_expression.get()); + } + else if (const auto * array_join_node = node->as()) + { + children.push_back(array_join_node->getTableExpression().get()); + } +} + static bool isFromJoinTree(const IQueryTreeNode * node_source, const IQueryTreeNode * tree_node) { if (node_source == tree_node) @@ -372,16 +397,10 @@ static bool isFromJoinTree(const IQueryTreeNode * node_source, const IQueryTreeN if (node_source == current) return true; - if (const auto * child_join_node = current->as()) - { - stack.push(child_join_node->getLeftTableExpression().get()); - stack.push(child_join_node->getRightTableExpression().get()); - } - - if (const auto * child_join_node = current->as()) - { - stack.push_range(child_join_node->getTableExpressions() | std::views::transform(&QueryTreeNodePtr::get)); - } + std::vector children; + collectJoinTreeChildTableExpressions(current, children); + for (const auto * child : children) + stack.push(child); } return false; } @@ -1949,44 +1968,42 @@ static void checkSemiAntiJoinTableAccess( const auto * current = stack.top(); stack.pop(); - if (const auto * cross_join_node = const_cast(current)->as()) + /// A JOIN node imposes a per-side restriction: determine which side contains the table + /// expression, check access if it is a SEMI/ANTI JOIN, then descend only into that side. + if (const auto * join_node = current->as()) { - for (const auto & table_expression : cross_join_node->getTableExpressions()) + bool is_from_left = isFromJoinTree(table_expression_node.get(), join_node->getLeftTableExpression().get()); + bool is_from_right = !is_from_left && isFromJoinTree(table_expression_node.get(), join_node->getRightTableExpression().get()); + + if (!is_from_left && !is_from_right) + continue; + + if (join_node->getStrictness() == JoinStrictness::Semi || join_node->getStrictness() == JoinStrictness::Anti) { - if (isFromJoinTree(table_expression_node.get(), table_expression.get())) - stack.push(table_expression.get()); + SemiAntiJoinSideChecker checker( + *join_node, + join_node->getStrictness(), + join_node->getKind(), + scope.context, + scope.resolving_join_on_expression); + JoinTableSide side = is_from_left ? JoinTableSide::Left : JoinTableSide::Right; + checker.throwIfTableAccessDenied(side, *node_for_error_message, *scope.scope_node); } + + stack.push(is_from_left ? join_node->getLeftTableExpression().get() : join_node->getRightTableExpression().get()); continue; } - const auto * join_node = const_cast(current)->as(); - if (!join_node) - continue; - - bool is_from_left = isFromJoinTree(table_expression_node.get(), join_node->getLeftTableExpression().get()); - bool is_from_right = !is_from_left && isFromJoinTree(table_expression_node.get(), join_node->getRightTableExpression().get()); - - if (!is_from_left && !is_from_right) - continue; - - /// This join contains the table expression; check access if it is a SEMI/ANTI JOIN. - if (join_node->getStrictness() == JoinStrictness::Semi || join_node->getStrictness() == JoinStrictness::Anti) + /// Other join-tree nodes (CROSS JOIN, ARRAY JOIN, ...) impose no per-side restriction on + /// their own; descend into whichever children contain the table expression so that any + /// SEMI/ANTI JOIN nested below (e.g. wrapped by ARRAY JOIN) is still reached and checked. + std::vector children; + collectJoinTreeChildTableExpressions(current, children); + for (const auto * child : children) { - SemiAntiJoinSideChecker checker( - *join_node, - join_node->getStrictness(), - join_node->getKind(), - scope.context, - scope.resolving_join_on_expression); - JoinTableSide side = is_from_left ? JoinTableSide::Left : JoinTableSide::Right; - checker.throwIfTableAccessDenied(side, *node_for_error_message, *scope.scope_node); + if (isFromJoinTree(table_expression_node.get(), child)) + stack.push(child); } - - /// Descend only into the branch that actually contains the table expression. - if (is_from_left) - stack.push(join_node->getLeftTableExpression().get()); - else - stack.push(join_node->getRightTableExpression().get()); } } diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 0dd2b56d80ef..6cbf3637539e 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -27,3 +27,4 @@ 1 1 1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index cd13199b6efd..e0c6e523ee2a 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -134,6 +134,14 @@ SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true CR -- t1 is preserved by the inner LEFT SEMI JOIN and should remain accessible. SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true CROSS JOIN (SELECT 1 AS c) t3; +-- (t1 LEFT SEMI JOIN t2) wrapped by ARRAY JOIN at the root: the traversal must descend through the +-- ARRAY JOIN node to reach the inner SEMI JOIN, so t2 stays non-preserved and inaccessible. +SELECT t2.* FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true ARRAY JOIN arr; -- { serverError UNKNOWN_IDENTIFIER } +-- t1 is preserved by the inner LEFT SEMI JOIN and should remain accessible under the ARRAY JOIN. +SELECT t1.a FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true ARRAY JOIN arr; +-- Same for ANTI JOIN under ARRAY JOIN. +SELECT t2.* FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ARRAY JOIN arr; -- { serverError UNKNOWN_IDENTIFIER } + -- Additional clause coverage: PREWHERE, GROUP BY, HAVING, QUALIFY, LIMIT BY, and ORDER BY -- 'PREWHERE'; From d19118d4a3c6e43aabdd3871e4e64c7d9166b1d4 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Fri, 10 Jul 2026 17:36:33 +0800 Subject: [PATCH 026/272] Reject SEMI/ANTI JOIN access from statically-dead branches The analyzer folds `if(false, ...)` and `multiIf(false, ...)` at analysis time by resolving the unreachable branch, swallowing any `Exception` it throws, and replacing the whole call with the live branch. That contract is what makes `if(0, missing_column, 42)` work. It also silently masked SEMI/ANTI JOIN access violations: `if(false, t2.b, 42) FROM ... LEFT SEMI JOIN t2 ON true` returned 42 instead of rejecting the reference to the non-preserved side. Introduce a dedicated error code `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED` so the access check has a distinct signal, and rethrow only that code from the dead-branch folding sites - `UNKNOWN_IDENTIFIER` (missing columns), unknown functions, cast failures, etc. continue to be swallowed. `SemiAntiJoinSideChecker::throwIfTableAccessDenied` uses the new code. In `tryResolveIdentifierFromJoin`, the skipped-side branch raises the new code when the lookup is a compound expression whose qualifier binds to the skipped side (`t2.b`) or a table-expression identifier whose bare name binds to the skipped side (used by the recursive-CTE self-reference remap for `x.*`). Also reorder the recursive-CTE remap in `resolveQualifiedMatcher` to run before `checkSemiAntiJoinTableAccess`: the check uses raw pointer identity via `isFromJoinTree`, so the synthetic self-reference node, which never appears in the join tree, would otherwise silently bypass the SEMI/ANTI restriction. Test 03913 assertions are migrated to the new code (line 70 stays on `UNKNOWN_IDENTIFIER` since it is a genuine "USING column absent from left" error, not a SEMI/ANTI rejection). Adds positive-regression cases for dead branches under SEMI/ANTI, and negative-regression cases that dead-branch folding must continue to swallow missing columns, unknown functions, and cast failures. Co-Authored-By: Claude Opus 4.7 --- src/Analyzer/Resolve/IdentifierResolver.cpp | 35 +++++- src/Analyzer/Resolve/QueryAnalyzer.cpp | 11 +- src/Analyzer/Resolve/resolveFunction.cpp | 15 ++- src/Common/ErrorCodes.cpp | 3 +- ...913_semi_anti_join_column_access.reference | 5 + .../03913_semi_anti_join_column_access.sql | 110 ++++++++++++++---- 6 files changed, 148 insertions(+), 31 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index d4d35175956e..de42613cc3f0 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -59,6 +59,7 @@ namespace ErrorCodes extern const int LOGICAL_ERROR; extern const int UNKNOWN_TABLE; extern const int TABLE_UUID_MISMATCH; + extern const int SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED; } QueryTreeNodePtr IdentifierResolver::convertJoinedColumnTypeToNullIfNeeded( @@ -1131,7 +1132,7 @@ void SemiAntiJoinSideChecker::throwIfTableAccessDenied( { const char * join_type_str = is_semi ? "SEMI" : "ANTI"; const char * side_str = skip_right ? "right" : "left"; - throw Exception(ErrorCodes::UNKNOWN_IDENTIFIER, + throw Exception(ErrorCodes::SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED, "Cannot access columns from the {} side of {} JOIN in this context. Expression {} is not available. In scope {}", side_str, join_type_str, @@ -1210,7 +1211,39 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I { /// Early check: if this side should be skipped, return immediately if (side_checker.shouldSkipSide(side)) + { + /// Surface a dedicated error code when the user is explicitly requesting something + /// from the non-preserved side, instead of falling through to a generic + /// `UNKNOWN_IDENTIFIER`. This matters because downstream dead-branch folding for + /// `if`/`multiIf` (see `resolveFunction.cpp`) swallows `UNKNOWN_IDENTIFIER` to + /// keep queries like `if(0, missing_column, 42)` working, and would otherwise + /// also swallow SEMI/ANTI access violations. + /// + /// Two shapes count as "explicit": + /// - compound expression like `t2.b`: the qualifier `t2` binds to the skipped side + /// - a table lookup like `x` (used by recursive-CTE self-reference remap): the + /// bare table name binds to the skipped side + const bool is_table_lookup = identifier_lookup.isTableExpressionLookup(); + const bool is_qualified_expr = identifier_lookup.isExpressionLookup() + && identifier_lookup.identifier.getPartsSize() > 1; + if (is_table_lookup || is_qualified_expr) + { + const auto & prefix = identifier_lookup.identifier.front(); + if (qualifierBindsToJoinSubtree(join_tree_node, prefix, scope)) + { + const char * join_type_str = side_checker.is_semi ? "SEMI" : "ANTI"; + const char * side_str = side == JoinTableSide::Left ? "left" : "right"; + throw Exception(ErrorCodes::SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED, + "Cannot access columns from the {} side of {} JOIN in this context. " + "Expression {} is not available. In scope {}", + side_str, + join_type_str, + identifier_lookup.identifier.getFullName(), + scope.scope_node->formatASTForErrorMessage()); + } + } return QueryTreeNodePtr{}; + } /// scope.join_using_columns holds raw pointers to this stack-local map. The pop must run /// even if tryResolveIdentifierFromJoinTreeNode throws: an UNKNOWN_IDENTIFIER from a diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 510cff4a88d8..fbe855d90dbc 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -2093,9 +2093,6 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveQualifiedMatcher(Qu scope.scope_node->formatASTForErrorMessage()); } - /// Check if the table is from the non-preserved side of a SEMI or ANTI JOIN - checkSemiAntiJoinTableAccess(table_expression_node, scope, matcher_node); - /// In the recursive term of a recursive CTE the qualifier names the CTE itself (e.g. `x.*` in /// `WITH RECURSIVE x AS (... UNION ALL SELECT x.* FROM x ...)`). The qualifier resolves through /// expression arguments to the synthetic self-reference table node registered in @@ -2104,6 +2101,11 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveQualifiedMatcher(Qu /// join-tree table expression (the `FROM x` clone). Remap the matcher to that initialized join-tree node so /// `x.*` expands the same columns, matching the column/unqualified-matcher behavior and avoiding the /// uninitialized-data path. + /// + /// The remap must happen BEFORE checkSemiAntiJoinTableAccess: that check uses raw pointer identity + /// via isFromJoinTree, so a synthetic self-reference node - which never appears in the join tree - + /// would silently bypass the SEMI/ANTI side restriction. Running the remap first lets the check + /// inspect the actual join-tree node. auto * nearest_query_scope = scope.getNearestQueryScope(); if (nearest_query_scope && table_identifier_resolve_result.isResolvedFromExpressionArguments() @@ -2129,6 +2131,9 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveQualifiedMatcher(Qu table_expression_node = std::move(remapped_table_expression_node); } + /// Check if the table is from the non-preserved side of a SEMI or ANTI JOIN + checkSemiAntiJoinTableAccess(table_expression_node, scope, matcher_node); + NamesAndTypes matched_columns; auto * table_expression_query_node = table_expression_node->as(); diff --git a/src/Analyzer/Resolve/resolveFunction.cpp b/src/Analyzer/Resolve/resolveFunction.cpp index 44644edda254..a76ef3a6b43f 100644 --- a/src/Analyzer/Resolve/resolveFunction.cpp +++ b/src/Analyzer/Resolve/resolveFunction.cpp @@ -68,6 +68,7 @@ namespace ErrorCodes extern const int LOGICAL_ERROR; extern const int UNSUPPORTED_METHOD; extern const int SUPPORT_IS_DISABLED; + extern const int SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED; } namespace Setting @@ -513,8 +514,14 @@ ProjectionNames QueryAnalyzer::resolveFunction(QueryTreeNodePtr & node, Identifi false /*allow_table_expression*/, allow_niladic_functions); } - catch (const Exception &) + catch (const Exception & e) { + /// SEMI/ANTI JOIN column access violations must not be masked by dead-branch + /// folding: they are compile-time access-control errors, not "unknown column" + /// lookups. Rethrow so the query is rejected even when the offending reference + /// sits in a statically unreachable branch of `if`. + if (e.code() == ErrorCodes::SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED) + throw; apply_constant_if_optimization = true; } @@ -646,8 +653,12 @@ ProjectionNames QueryAnalyzer::resolveFunction(QueryTreeNodePtr & node, Identifi false /*allow_table_expression*/, allow_niladic_functions); } - catch (const Exception &) + catch (const Exception & e) { + /// See the `if` special case above: SEMI/ANTI JOIN access violations + /// must not be swallowed by dead-branch folding. + if (e.code() == ErrorCodes::SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED) + throw; apply_constant_multi_if_optimization = true; } } diff --git a/src/Common/ErrorCodes.cpp b/src/Common/ErrorCodes.cpp index b20efd684a3c..67e64df77b30 100644 --- a/src/Common/ErrorCodes.cpp +++ b/src/Common/ErrorCodes.cpp @@ -676,6 +676,7 @@ M(1007, ILLEGAL_STREAM) \ M(1008, TEMPORARY_DATA_NOT_IN_CACHE) \ M(1009, S3_OBJECT_CHANGED_DURING_READ) \ + M(1010, SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED) \ /* See END */ #ifdef APPLY_FOR_EXTERNAL_ERROR_CODES @@ -692,7 +693,7 @@ namespace ErrorCodes APPLY_FOR_ERROR_CODES(M) #undef M - constexpr ErrorCode END = 1009; + constexpr ErrorCode END = 1010; ErrorPairHolder values[END + 1]{}; struct ErrorCodesNames diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 6cbf3637539e..7488116ced6c 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -28,3 +28,8 @@ 1 1 1 +1 +42 +42 +42 +42 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index e0c6e523ee2a..5a327c1e909c 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -49,47 +49,47 @@ SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- Explicit column reference: preserved side works, non-preserved side fails SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; -SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- Default behavior (both settings = 0): returns columns from both sides SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false SETTINGS anti_join_compatibility = 0; -- Test that non-preserved side columns are not accessible with qualified references -SELECT d.* FROM (SELECT 1 AS id, 2 AS value) AS l SEMI LEFT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } -SELECT l.* FROM (SELECT 1 AS id, 2 AS value) AS l SEMI RIGHT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } -SELECT d.* FROM (SELECT 1 AS id, 2 AS value) AS l ANTI LEFT JOIN (SELECT 2 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } -SELECT l.* FROM (SELECT 2 AS id, 2 AS value) AS l ANTI RIGHT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } +SELECT d.* FROM (SELECT 1 AS id, 2 AS value) AS l SEMI LEFT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT l.* FROM (SELECT 1 AS id, 2 AS value) AS l SEMI RIGHT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT d.* FROM (SELECT 1 AS id, 2 AS value) AS l ANTI LEFT JOIN (SELECT 2 AS id, 3 AS values) AS d USING id; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT l.* FROM (SELECT 2 AS id, 2 AS value) AS l ANTI RIGHT JOIN (SELECT 1 AS id, 3 AS values) AS d USING id; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- Test USING column access SELECT id FROM (SELECT 1 AS id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING id; SELECT l.id FROM (SELECT 1 AS id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING id; -SELECT d.id FROM (SELECT 1 AS id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING id; -- { serverError UNKNOWN_IDENTIFIER } +SELECT d.id FROM (SELECT 1 AS id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING id; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- Test USING with non-existent columns SELECT * FROM (SELECT 1 AS other_id) AS l SEMI LEFT JOIN (SELECT 1 AS id) AS d USING (id); -- { serverError UNKNOWN_IDENTIFIER } -- Test WHERE clause: non-preserved side columns are not accessible -- LEFT SEMI JOIN: cannot reference right side columns in WHERE -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t2.b = 2; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t1.a = 1; -- RIGHT SEMI JOIN: cannot reference left side columns in WHERE -SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t1.a = 1; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t1.a = 1; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE t2.b = 2; -- LEFT ANTI JOIN: cannot reference right side columns in WHERE -SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t2.b = 2; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t1.a = 1; -- RIGHT ANTI JOIN: cannot reference left side columns in WHERE -SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t1.a = 1; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t1.a = 1; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT * FROM (SELECT 1 AS a) t1 RIGHT ANTI JOIN (SELECT 2 AS b) t2 ON false WHERE t2.b = 2; -- Test nested JOINs: SEMI/ANTI JOIN restrictions apply only within their scope -- Nested: (t1 LEFT SEMI JOIN t2) LEFT JOIN t3 -- t2 is non-preserved in inner SEMI JOIN, t3 is in outer regular JOIN -SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON true; -- Nested: t1 LEFT JOIN (t2 LEFT SEMI JOIN t3) @@ -99,7 +99,7 @@ SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT JOIN (SELECT * FROM (SELECT 2 AS b) t2 -- Nested: (t1 LEFT SEMI JOIN t2) LEFT JOIN t3 -- In outer JOIN's ON clause, t2 (non-preserved in inner SEMI JOIN) should still be inaccessible -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON t2.b = t3.c; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON t2.b = t3.c; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- But t1 (preserved in inner SEMI JOIN) should be accessible in outer JOIN's ON clause SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true LEFT JOIN (SELECT 3 AS c) t3 ON t1.a = t3.c; -- Test that inner SEMI JOIN's own ON expression can still access both sides @@ -116,31 +116,31 @@ LEFT SEMI JOIN (SELECT 1 AS b) t2 SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON arrayExists(x -> ignore(t2.*) = 0, [1]); SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON arrayExists(x -> t2.b = 2, [1]); -- The exemption must not leak past the ON clause: the same lambda in WHERE still cannot see the non-preserved side. -SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE arrayExists(x -> ignore(t2.*) = 0, [1]); -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true WHERE arrayExists(x -> ignore(t2.*) = 0, [1]); -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- (t1 LEFT SEMI JOIN t2) LEFT SEMI JOIN t3: outer sees t2 on its preserved left side, but -- the inner LEFT SEMI JOIN places t2 on its non-preserved right side -- must be denied. -SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LEFT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LEFT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- t1 is on the preserved left of both joins -- must be allowed. SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true LEFT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- (t1 LEFT SEMI JOIN t2) RIGHT SEMI JOIN t3: outer RIGHT SEMI makes its entire left subtree -- (including t1) non-preserved, regardless of the inner join's own preservation. -SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- t3 is on the preserved right of the outer RIGHT SEMI JOIN -- must be allowed. SELECT t3.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; -- (t1 LEFT SEMI JOIN t2) CROSS JOIN t3: t2 remains non-preserved due to the inner LEFT SEMI JOIN -- and must stay inaccessible even when wrapped by CROSS JOIN at the root. -SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true CROSS JOIN (SELECT 1 AS c) t3; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true CROSS JOIN (SELECT 1 AS c) t3; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- t1 is preserved by the inner LEFT SEMI JOIN and should remain accessible. SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON true CROSS JOIN (SELECT 1 AS c) t3; -- (t1 LEFT SEMI JOIN t2) wrapped by ARRAY JOIN at the root: the traversal must descend through the -- ARRAY JOIN node to reach the inner SEMI JOIN, so t2 stays non-preserved and inaccessible. -SELECT t2.* FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true ARRAY JOIN arr; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t2.* FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true ARRAY JOIN arr; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- t1 is preserved by the inner LEFT SEMI JOIN and should remain accessible under the ARRAY JOIN. SELECT t1.a FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true ARRAY JOIN arr; -- Same for ANTI JOIN under ARRAY JOIN. -SELECT t2.* FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ARRAY JOIN arr; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t2.* FROM (SELECT [1] AS arr, 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ARRAY JOIN arr; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } -- Additional clause coverage: PREWHERE, GROUP BY, HAVING, QUALIFY, LIMIT BY, and ORDER BY @@ -151,27 +151,89 @@ CREATE TABLE semi_anti_prewhere_left (number UInt64) ENGINE = MergeTree ORDER BY CREATE TABLE semi_anti_prewhere_right (number UInt64) ENGINE = MergeTree ORDER BY number; INSERT INTO semi_anti_prewhere_left VALUES (1); INSERT INTO semi_anti_prewhere_right VALUES (1); -SELECT * FROM semi_anti_prewhere_left AS t1 LEFT SEMI JOIN semi_anti_prewhere_right AS t2 ON true PREWHERE t2.number = 1; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM semi_anti_prewhere_left AS t1 LEFT SEMI JOIN semi_anti_prewhere_right AS t2 ON true PREWHERE t2.number = 1; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT * FROM semi_anti_prewhere_left AS t1 LEFT SEMI JOIN semi_anti_prewhere_right AS t2 ON true PREWHERE t1.number = 1; DROP TABLE semi_anti_prewhere_left; DROP TABLE semi_anti_prewhere_right; -- 'HAVING'; -SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a HAVING t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a HAVING t2.b = 2; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a HAVING t1.a = 1; -- 'GROUP BY'; -SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t2.b; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t2.b; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true GROUP BY t1.a; -- 'QUALIFY'; -SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true QUALIFY row_number() OVER () = 1 AND t2.b = 2; -- { serverError UNKNOWN_IDENTIFIER } +SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true QUALIFY row_number() OVER () = 1 AND t2.b = 2; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT t1.a FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true QUALIFY row_number() OVER () = 1 AND t1.a = 1; -- 'LIMIT BY'; -SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false LIMIT 1 BY t2.b; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false LIMIT 1 BY t2.b; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false LIMIT 1 BY t1.a; -- 'ORDER BY'; -SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ORDER BY t2.b; -- { serverError UNKNOWN_IDENTIFIER } +SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ORDER BY t2.b; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false ORDER BY t1.a; + +-- Recursive CTE: `x.*` in the recursive term goes through the self-reference remap in +-- `resolveQualifiedMatcher`. That remap must happen before the SEMI/ANTI access check so the +-- check sees the actual join-tree node rather than the synthetic self-reference (which would +-- silently bypass the restriction because `isFromJoinTree` compares raw pointers). +-- `x` is the non-preserved right side of the LEFT SEMI JOIN, so `x.*` must be rejected. +WITH RECURSIVE x AS +( + SELECT 1 AS a + UNION ALL + SELECT x.* + FROM numbers(0) AS t + LEFT SEMI JOIN x ON true +) +SELECT * FROM x; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } + +-- Same shape with LEFT ANTI JOIN: `x` is non-preserved, so `x.*` must be rejected. +WITH RECURSIVE x AS +( + SELECT 1 AS a + UNION ALL + SELECT x.* + FROM numbers(0) AS t + LEFT ANTI JOIN x ON false +) +SELECT * FROM x; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } + +-- `x` on the preserved (left) side of a LEFT SEMI JOIN inside the recursive term must be allowed. +WITH RECURSIVE x AS +( + SELECT 1 AS a + UNION ALL + SELECT x.a + FROM x + LEFT SEMI JOIN numbers(0) AS t ON true +) +SELECT * FROM x ORDER BY a; + +-- Static dead-branch access-control: `if(false, ...)` / `multiIf(false, ...)` fold unreachable +-- branches after analysis, and the analyzer swallows `UNKNOWN_IDENTIFIER` from those branches +-- so that queries like `if(0, missing_column, 42)` keep working. SEMI/ANTI JOIN access +-- violations are a different class of error - they must not be silently masked, even inside +-- a statically-unreachable branch. Enforced by a dedicated error code. +SELECT if(false, t2.b, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT if(false, t2.*, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT if(false, ignore(t2.b), 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT multiIf(false, t2.b, false, t2.b, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT if(false, t2.b, 42) FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- Unqualified reference: falls back to `UNKNOWN_IDENTIFIER` and is still folded away, matching +-- the "missing column" contract. Frozen behavior. +SELECT if(false, b, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; +-- Live branch still receives normal access-control (control case). +SELECT if(true, t2.b, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } + +-- Negative-regression: the conditional rethrow above must NOT collateral-damage the existing +-- dead-branch folding contract. Non-SEMI/ANTI analysis errors inside a dead branch (missing +-- column, unknown function, cast failures) must continue to be swallowed and the whole `if` +-- collapsed to the live branch. Any of these starting to throw would signal that the +-- dead-branch folding in `resolveFunction.cpp` has been over-tightened. +SELECT if(false, does_not_exist, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; +SELECT if(false, nonexistent_fn_xyz(1), 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; +SELECT if(false, cast('abc' AS Int32), 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; From 5d8681ad0d92fa3c0a56e5482497ac6097a2eb92 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 19 Jul 2026 08:52:09 +0000 Subject: [PATCH 027/272] Exempt inner JOIN ON resolution from ancestor SEMI/ANTI restrictions Identifier and matcher resolution starts from the root of the join tree, but the JOIN ON exemption in SemiAntiJoinSideChecker only matched the exact JoinNode whose ON was being resolved. In a nested shape like (t1 LEFT SEMI JOIN t2 ON t1.a = t2.b) RIGHT SEMI JOIN t3 ON true resolving `t1.a` or `t2.b` for the inner join's own `ON` first visits the outer `RIGHT SEMI JOIN`, which treated its whole left subtree as non-preserved and rejected the reference before resolution ever reached the inner join. The same ancestor filtering also hit `t2.*` through `checkSemiAntiJoinTableAccess` and the unqualified matcher. Exempt an ancestor join whenever the join whose `ON` is currently being resolved lies anywhere inside its subtree: at execution time the inner `ON` is evaluated before the ancestor filters its non-preserved side, so ancestor restrictions must not apply there. Restrictions of joins nested BELOW the resolving `ON` still apply, since their output is already formed and filtered; a control test covers that direction. Addresses the AI review finding on the pull request: https://github.com/ClickHouse/ClickHouse/pull/96662 Co-Authored-By: Claude Fable 5 --- src/Analyzer/Resolve/IdentifierResolver.cpp | 40 ++++++++++++++++++- ...913_semi_anti_join_column_access.reference | 3 ++ .../03913_semi_anti_join_column_access.sql | 31 ++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index de42613cc3f0..ba03a7807ee2 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1089,6 +1089,34 @@ QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, return function_node; } +/// Returns true if `target` is `root` itself or a table expression nested anywhere inside +/// the join subtree rooted at `root` (descending through JOIN, CROSS JOIN and ARRAY JOIN). +static bool joinSubtreeContains(const IQueryTreeNode * root, const IQueryTreeNode * target) +{ + if (!root) + return false; + + if (root == target) + return true; + + if (const auto * join = root->as()) + return joinSubtreeContains(join->getLeftTableExpression().get(), target) + || joinSubtreeContains(join->getRightTableExpression().get(), target); + + if (const auto * cross_join = root->as()) + { + for (const auto & table_expression : cross_join->getTableExpressions()) + if (joinSubtreeContains(table_expression.get(), target)) + return true; + return false; + } + + if (const auto * array_join = root->as()) + return joinSubtreeContains(array_join->getTableExpression().get(), target); + + return false; +} + /// Helper structure to check SEMI/ANTI JOIN side access restrictions SemiAntiJoinSideChecker::SemiAntiJoinSideChecker( const JoinNode & join_node, @@ -1108,8 +1136,16 @@ SemiAntiJoinSideChecker::SemiAntiJoinSideChecker( skip_left = skip_non_preserved_side && isRight(kind); skip_right = skip_non_preserved_side && isLeft(kind); - /// If we are resolving the ON expression of THIS specific JOIN node, allow access to both sides - if (resolving_join_on_expression && resolving_join_on_expression == &join_node) + /// If we are resolving the ON expression of THIS join, or of a join nested anywhere inside + /// this join's subtree, allow access to both sides. Identifier resolution always starts from + /// the root of the join tree, so while an inner join's ON is being resolved, every ancestor + /// SEMI/ANTI join is visited first. At execution time the inner ON is evaluated before the + /// ancestor filters its non-preserved side, so ancestor restrictions must not apply here - + /// otherwise `(t1 LEFT SEMI JOIN t2 ON t1.a = t2.b) RIGHT SEMI JOIN t3` would reject `t1.a` + /// and `t2.b` in the inner ON. Restrictions of joins nested BELOW the join whose ON is being + /// resolved still apply: their output is already formed and filtered. + if ((skip_left || skip_right) && resolving_join_on_expression + && joinSubtreeContains(&join_node, resolving_join_on_expression)) { skip_left = false; skip_right = false; diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 7488116ced6c..e86222899171 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -33,3 +33,6 @@ 42 42 42 +1 +1 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 5a327c1e909c..2b43885e3666 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -237,3 +237,34 @@ SELECT if(true, t2.b, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) SELECT if(false, does_not_exist, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; SELECT if(false, nonexistent_fn_xyz(1), 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; SELECT if(false, cast('abc' AS Int32), 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 2 AS b) t2 ON true; + +-- Nested join trees: while an inner join's own ON expression is being resolved, ancestor +-- SEMI/ANTI joins must not restrict access. Identifier resolution starts from the root of +-- the join tree, so every ancestor join is visited before the inner join is reached, but at +-- execution time the inner ON is evaluated before the ancestor filters its non-preserved +-- side. Here the outer RIGHT SEMI JOIN treats the whole left subtree as non-preserved, yet +-- `t1.a` and `t2.b` inside the inner LEFT SEMI JOIN's own ON must still resolve. +SELECT * +FROM (SELECT 1 AS a) t1 +LEFT SEMI JOIN (SELECT 1 AS b) t2 ON t1.a = t2.b +RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; + +-- A qualified matcher over the inner join's own sides inside its ON is exempt as well. +SELECT * +FROM (SELECT 1 AS a) t1 +LEFT SEMI JOIN (SELECT 1 AS b) t2 ON ignore(t2.*) = 0 AND t1.a = t2.b +RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; + +-- Same shape with an inner ANTI JOIN under an outer SEMI JOIN. +SELECT * +FROM (SELECT 1 AS a) t1 +LEFT ANTI JOIN (SELECT 2 AS b) t2 ON t1.a = t2.b +RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON true; + +-- Control: the exemption only lifts ANCESTOR restrictions. A join nested BELOW the join whose +-- ON is being resolved has already formed and filtered its output, so referencing the inner +-- LEFT SEMI JOIN's non-preserved side (`t2.b`) from the OUTER join's ON must still be rejected. +SELECT t3.c +FROM (SELECT 1 AS a) t1 +LEFT SEMI JOIN (SELECT 1 AS b) t2 ON t1.a = t2.b +RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON t2.b = t3.c; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } From 7a4a45921ccc9da7bb74032380605f86deca54c0 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 19 Jul 2026 21:39:52 +0000 Subject: [PATCH 028/272] Fix style check: replace "new analyzer" with "the analyzer" The style check rejects the phrase "new analyzer" because the analyzer has been enabled by default since ClickHouse 24.3 and is no longer new. Reword the `semi_join_compatibility`/`anti_join_compatibility` setting descriptions in `Settings.cpp`, the `select/join.md` docs, and the `03913_semi_anti_join_column_access.sql` comment accordingly. --- docs/en/sql-reference/statements/select/join.md | 2 +- src/Core/Settings.cpp | 4 ++-- .../0_stateless/03913_semi_anti_join_column_access.sql | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/en/sql-reference/statements/select/join.md b/docs/en/sql-reference/statements/select/join.md index 50ef580a3b76..627edb9112ef 100644 --- a/docs/en/sql-reference/statements/select/join.md +++ b/docs/en/sql-reference/statements/select/join.md @@ -49,7 +49,7 @@ Additional join types available in ClickHouse are: | `PASTE JOIN` | Performs a horizontal concatenation of two tables. | :::note -With the new analyzer, [`semi_join_compatibility`](../../../operations/settings/settings.md#semi_join_compatibility) and [`anti_join_compatibility`](../../../operations/settings/settings.md#anti_join_compatibility) make `SEMI` and `ANTI` joins expose only their preserved side to expressions resolved after the join result is formed. +With the analyzer, [`semi_join_compatibility`](../../../operations/settings/settings.md#semi_join_compatibility) and [`anti_join_compatibility`](../../../operations/settings/settings.md#anti_join_compatibility) make `SEMI` and `ANTI` joins expose only their preserved side to expressions resolved after the join result is formed. - `LEFT SEMI JOIN` and `LEFT ANTI JOIN` expose only left-side columns. - `RIGHT SEMI JOIN` and `RIGHT ANTI JOIN` expose only right-side columns. diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index 2d95cfccc51d..87d2b793463c 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7847,7 +7847,7 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. )", 0) \ DECLARE(Bool, semi_join_compatibility, false, R"( -When enabled with the new analyzer, `SEMI` JOIN restricts column access to the preserved side per SQL standard. +When enabled with the analyzer, `SEMI` JOIN restricts column access to the preserved side per SQL standard. For `LEFT SEMI JOIN` only left table columns are accessible, for `RIGHT SEMI JOIN` only right table columns. This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. @@ -7855,7 +7855,7 @@ The `JOIN ON` expression of the same `JOIN` can access both sides regardless of When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. )", 0) \ DECLARE(Bool, anti_join_compatibility, false, R"( -When enabled with the new analyzer, `ANTI` JOIN restricts column access to the preserved side per SQL standard. +When enabled with the analyzer, `ANTI` JOIN restricts column access to the preserved side per SQL standard. For `LEFT ANTI JOIN` only left table columns are accessible, for `RIGHT ANTI JOIN` only right table columns. This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 2b43885e3666..6bd9871d4cdb 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -13,7 +13,7 @@ -- -- Note: JOIN ON expressions can access both sides regardless of settings. --- only with new analyzer +-- only with the analyzer SET allow_experimental_analyzer = 1; -- Test ANTI JOIN setting From f2f38539caeeddb2fd84a389986e9c689e1c3fa6 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Mon, 20 Jul 2026 15:33:11 +0800 Subject: [PATCH 029/272] Defer SEMI/ANTI JOIN access denial until resolution completes Preserve valid subcolumn resolution from the retained side before reporting access to the skipped side, and clarify the related analyzer documentation. Co-authored-by: Cursor --- .../sql-reference/statements/select/join.md | 2 +- src/Analyzer/Resolve/IdentifierResolver.cpp | 30 +++++++++++-------- src/Core/Settings.cpp | 4 +-- ...913_semi_anti_join_column_access.reference | 1 + .../03913_semi_anti_join_column_access.sql | 14 ++++++++- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/en/sql-reference/statements/select/join.md b/docs/en/sql-reference/statements/select/join.md index 627edb9112ef..e34f3749076e 100644 --- a/docs/en/sql-reference/statements/select/join.md +++ b/docs/en/sql-reference/statements/select/join.md @@ -49,7 +49,7 @@ Additional join types available in ClickHouse are: | `PASTE JOIN` | Performs a horizontal concatenation of two tables. | :::note -With the analyzer, [`semi_join_compatibility`](../../../operations/settings/settings.md#semi_join_compatibility) and [`anti_join_compatibility`](../../../operations/settings/settings.md#anti_join_compatibility) make `SEMI` and `ANTI` joins expose only their preserved side to expressions resolved after the join result is formed. +When using the analyzer, enabling [`semi_join_compatibility`](../../../operations/settings/settings.md#semi_join_compatibility) or [`anti_join_compatibility`](../../../operations/settings/settings.md#anti_join_compatibility) makes the corresponding join expose only its preserved side to expressions resolved after the join result is formed. - `LEFT SEMI JOIN` and `LEFT ANTI JOIN` expose only left-side columns. - `RIGHT SEMI JOIN` and `RIGHT ANTI JOIN` expose only right-side columns. diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index ba03a7807ee2..1bbdea2a3ead 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1242,10 +1242,13 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I /// Check SEMI/ANTI JOIN side access restrictions SemiAntiJoinSideChecker side_checker(from_join_node, join_strictness, join_kind, scope.context, scope.resolving_join_on_expression); + std::optional denied_qualified_access; auto try_resolve_identifier_from_join_tree_node = [&](const QueryTreeNodePtr & join_tree_node, bool may_be_override_by_using_column, JoinTableSide side) { - /// Early check: if this side should be skipped, return immediately + /// Do not resolve identifiers from a non-preserved side. For an explicitly qualified + /// identifier, defer the dedicated access-denied exception until the preserved side has + /// also been tried: the qualifier can name a column with a matching subcolumn there. if (side_checker.shouldSkipSide(side)) { /// Surface a dedicated error code when the user is explicitly requesting something @@ -1266,17 +1269,7 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I { const auto & prefix = identifier_lookup.identifier.front(); if (qualifierBindsToJoinSubtree(join_tree_node, prefix, scope)) - { - const char * join_type_str = side_checker.is_semi ? "SEMI" : "ANTI"; - const char * side_str = side == JoinTableSide::Left ? "left" : "right"; - throw Exception(ErrorCodes::SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED, - "Cannot access columns from the {} side of {} JOIN in this context. " - "Expression {} is not available. In scope {}", - side_str, - join_type_str, - identifier_lookup.identifier.getFullName(), - scope.scope_node->formatASTForErrorMessage()); - } + denied_qualified_access = side; } return QueryTreeNodePtr{}; } @@ -1329,6 +1322,19 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I if (!binds_left || binds_right) right_resolved_identifier = try_resolve_identifier_from_join_tree_node(from_join_node.getRightTableExpression(), join_kind != JoinKind::Right, JoinTableSide::Right); + if (!left_resolved_identifier && !right_resolved_identifier && denied_qualified_access) + { + const char * join_type_str = side_checker.is_semi ? "SEMI" : "ANTI"; + const char * side_str = *denied_qualified_access == JoinTableSide::Left ? "left" : "right"; + throw Exception(ErrorCodes::SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED, + "Cannot access columns from the {} side of {} JOIN in this context. " + "Expression {} is not available. In scope {}", + side_str, + join_type_str, + identifier_lookup.identifier.getFullName(), + scope.scope_node->formatASTForErrorMessage()); + } + if (!identifier_lookup.isExpressionLookup()) { if (left_resolved_identifier && right_resolved_identifier) diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index 87d2b793463c..c2ff1f7fbb0f 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7847,7 +7847,7 @@ Force to resolve identifier in JOIN USING from projection (for example, in `SELE Allow to add compound identifiers to nested. This is a compatibility setting because it changes the query result. When disabled, `SELECT a.b.c FROM table ARRAY JOIN a` does not work, and `SELECT a FROM table` does not include `a.b.c` column into `Nested a` result. )", 0) \ DECLARE(Bool, semi_join_compatibility, false, R"( -When enabled with the analyzer, `SEMI` JOIN restricts column access to the preserved side per SQL standard. +When enabled, this setting makes the analyzer restrict `SEMI` JOIN column access to the preserved side in accordance with the SQL standard. For `LEFT SEMI JOIN` only left table columns are accessible, for `RIGHT SEMI JOIN` only right table columns. This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. @@ -7855,7 +7855,7 @@ The `JOIN ON` expression of the same `JOIN` can access both sides regardless of When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. )", 0) \ DECLARE(Bool, anti_join_compatibility, false, R"( -When enabled with the analyzer, `ANTI` JOIN restricts column access to the preserved side per SQL standard. +When enabled, this setting makes the analyzer restrict `ANTI` JOIN column access to the preserved side in accordance with the SQL standard. For `LEFT ANTI JOIN` only left table columns are accessible, for `RIGHT ANTI JOIN` only right table columns. This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index e86222899171..d0daa5b2b9a3 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -5,6 +5,7 @@ 1 2 1 +1 1 0 1 1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 6bd9871d4cdb..4f193a6b7e29 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -13,7 +13,7 @@ -- -- Note: JOIN ON expressions can access both sides regardless of settings. --- only with the analyzer +-- These settings require the analyzer. SET allow_experimental_analyzer = 1; -- Test ANTI JOIN setting @@ -51,6 +51,18 @@ SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN (SELECT 2 AS b) t2 ON true; SELECT t1.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; SELECT t2.* FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- With the default subcolumn-over-alias resolution rule, a qualified-looking identifier can +-- still resolve to a Tuple subcolumn on the preserved side. Only reject it when alias-prefix +-- resolution is explicitly preferred. +SELECT t2.b +FROM (SELECT CAST(tuple(1), 'Tuple(b UInt8)') AS t2) AS l +LEFT SEMI JOIN (SELECT 1 AS b) AS t2 ON true +SETTINGS analyzer_compatibility_prefer_alias_over_subcolumn = 0; +SELECT t2.b +FROM (SELECT CAST(tuple(1), 'Tuple(b UInt8)') AS t2) AS l +LEFT SEMI JOIN (SELECT 1 AS b) AS t2 ON true +SETTINGS analyzer_compatibility_prefer_alias_over_subcolumn = 1; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } + -- Default behavior (both settings = 0): returns columns from both sides SELECT * FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN (SELECT 2 AS b) t2 ON false SETTINGS anti_join_compatibility = 0; From 8f3011c6e6bd7c5217887f9cbf49df1023178cfb Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Mon, 20 Jul 2026 17:27:52 +0800 Subject: [PATCH 030/272] Recognize fully qualified db.table qualifiers in SEMI/ANTI JOIN access check The skipped-side access check in `tryResolveIdentifierFromJoin` only compared `identifier.front()` against a table alias or `table_name`, so a fully qualified reference like `db.table.column` was never attributed to the skipped side. With `semi_join_compatibility = 1`, a query such as `SELECT if(false, default.r.b, 42) FROM default.l LEFT SEMI JOIN default.r ON true` left `denied_qualified_access` empty, fell back to `UNKNOWN_IDENTIFIER`, and had the dead branch folded to `42` in `resolveFunction.cpp` instead of raising `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED`. `qualifierBindsToJoinSubtree` now takes the full identifier and applies the same binding rules as normal table-expression resolution: a single-part qualifier matches a table alias or table name, and a two-part qualifier matches `database.table`. Extend `03913_semi_anti_join_column_access` with fully qualified and table-qualified references, using the `{CLICKHOUSE_DATABASE:Identifier}` parameter to reach the runtime database name. Co-authored-by: Cursor --- src/Analyzer/Resolve/IdentifierResolver.cpp | 48 +++++++++++++------ ...913_semi_anti_join_column_access.reference | 1 + .../03913_semi_anti_join_column_access.sql | 23 +++++++++ 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index 1bbdea2a3ead..4d9e34b0265e 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1177,15 +1177,25 @@ void SemiAntiJoinSideChecker::throwIfTableAccessDenied( } } +/** Check whether the leading table qualifier of `identifier` binds to some table expression + * inside `join_tree_node`. This mirrors the qualifier binding rules used by normal + * table-expression resolution (see `tryBindIdentifierToTableExpression`): + * - a single-part qualifier matches a table alias or a table name; + * - a two-part qualifier matches a fully qualified `database.table` reference. + * Recognizing the two-part form is required so that fully qualified references such as + * `db.table.column` are correctly attributed to the skipped side of a SEMI/ANTI JOIN. + */ static bool qualifierBindsToJoinSubtree( const QueryTreeNodePtr & join_tree_node, - const std::string & qualifier, + const Identifier & identifier, const IdentifierResolveScope & scope) { - if (!join_tree_node || qualifier.empty()) + if (!join_tree_node || identifier.empty()) return false; - if (join_tree_node->hasAlias() && join_tree_node->getAlias() == qualifier) + const auto & path_start = identifier.front(); + + if (join_tree_node->hasAlias() && join_tree_node->getAlias() == path_start) return true; switch (join_tree_node->getNodeType()) @@ -1193,21 +1203,21 @@ static bool qualifierBindsToJoinSubtree( case QueryTreeNodeType::JOIN: { const auto & join = join_tree_node->as(); - return qualifierBindsToJoinSubtree(join.getLeftTableExpression(), qualifier, scope) - || qualifierBindsToJoinSubtree(join.getRightTableExpression(), qualifier, scope); + return qualifierBindsToJoinSubtree(join.getLeftTableExpression(), identifier, scope) + || qualifierBindsToJoinSubtree(join.getRightTableExpression(), identifier, scope); } case QueryTreeNodeType::CROSS_JOIN: { const auto & cross = join_tree_node->as(); for (const auto & expr : cross.getTableExpressions()) - if (qualifierBindsToJoinSubtree(expr, qualifier, scope)) + if (qualifierBindsToJoinSubtree(expr, identifier, scope)) return true; return false; } case QueryTreeNodeType::ARRAY_JOIN: { const auto & arr = join_tree_node->as(); - return qualifierBindsToJoinSubtree(arr.getTableExpression(), qualifier, scope); + return qualifierBindsToJoinSubtree(arr.getTableExpression(), identifier, scope); } default: break; @@ -1216,7 +1226,18 @@ static bool qualifierBindsToJoinSubtree( auto it = scope.table_expression_node_to_data.find(join_tree_node); if (it == scope.table_expression_node_to_data.end()) return false; - return !it->second.table_name.empty() && it->second.table_name == qualifier; + + const auto & table_name = it->second.table_name; + const auto & database_name = it->second.database_name; + + if (!table_name.empty() && path_start == table_name) + return true; + + if (!database_name.empty() && identifier.getPartsSize() > 1 + && path_start == database_name && identifier[1] == table_name) + return true; + + return false; } IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const IdentifierLookup & identifier_lookup, @@ -1259,7 +1280,8 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I /// also swallow SEMI/ANTI access violations. /// /// Two shapes count as "explicit": - /// - compound expression like `t2.b`: the qualifier `t2` binds to the skipped side + /// - compound expression like `t2.b` or `db.t2.b`: the qualifier (`t2` / `db.t2`) + /// binds to the skipped side /// - a table lookup like `x` (used by recursive-CTE self-reference remap): the /// bare table name binds to the skipped side const bool is_table_lookup = identifier_lookup.isTableExpressionLookup(); @@ -1267,8 +1289,7 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I && identifier_lookup.identifier.getPartsSize() > 1; if (is_table_lookup || is_qualified_expr) { - const auto & prefix = identifier_lookup.identifier.front(); - if (qualifierBindsToJoinSubtree(join_tree_node, prefix, scope)) + if (qualifierBindsToJoinSubtree(join_tree_node, identifier_lookup.identifier, scope)) denied_qualified_access = side; } return QueryTreeNodePtr{}; @@ -1310,9 +1331,8 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I bool binds_right = true; if (prefer_alias && identifier_lookup.isExpressionLookup() && identifier_lookup.identifier.getPartsSize() > 1) { - const auto & path_start = identifier_lookup.identifier.front(); - binds_left = qualifierBindsToJoinSubtree(from_join_node.getLeftTableExpression(), path_start, scope); - binds_right = qualifierBindsToJoinSubtree(from_join_node.getRightTableExpression(), path_start, scope); + binds_left = qualifierBindsToJoinSubtree(from_join_node.getLeftTableExpression(), identifier_lookup.identifier, scope); + binds_right = qualifierBindsToJoinSubtree(from_join_node.getRightTableExpression(), identifier_lookup.identifier, scope); } QueryTreeNodePtr left_resolved_identifier = nullptr; diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index d0daa5b2b9a3..4c0638a9c100 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -37,3 +37,4 @@ 1 1 1 +42 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 4f193a6b7e29..38fadf733643 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -280,3 +280,26 @@ SELECT t3.c FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN (SELECT 1 AS b) t2 ON t1.a = t2.b RIGHT SEMI JOIN (SELECT 1 AS c) t3 ON t2.b = t3.c; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } + +-- Fully qualified `database.table.column` references to the non-preserved side must be recognized +-- with the same binding rules as normal table-expression resolution: a single-part table name and +-- a two-part `database.table` qualifier. Otherwise a fully qualified reference inside a statically +-- dead `if(false, ...)` branch misses the access check, falls back to UNKNOWN_IDENTIFIER, and gets +-- folded away (returning 42) instead of raising SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED. +-- The current database name is only available via the {CLICKHOUSE_DATABASE:Identifier} parameter. +DROP TABLE IF EXISTS semi_anti_qual_l; +DROP TABLE IF EXISTS semi_anti_qual_r; +CREATE TABLE semi_anti_qual_l (a UInt64) ENGINE = Memory; +CREATE TABLE semi_anti_qual_r (b UInt64) ENGINE = Memory; +INSERT INTO semi_anti_qual_l VALUES (1); +INSERT INTO semi_anti_qual_r VALUES (2); +-- Fully qualified `db.r.b` on the non-preserved right side inside a dead branch: denied. +SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- Table-qualified `r.b` on the non-preserved side inside a dead branch: denied. +SELECT if(false, semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- Fully qualified reference on the preserved left side is fine; the dead branch folds to 42. +SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l.a, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; +-- Live branch referencing the fully qualified non-preserved side: also denied. +SELECT if(true, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +DROP TABLE semi_anti_qual_l; +DROP TABLE semi_anti_qual_r; From f532f3ca48fc439c0da40494ae5974d4da5935a3 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Mon, 20 Jul 2026 19:18:47 +0800 Subject: [PATCH 031/272] Update semi/anti_join_compatibility descriptions for the dedicated error code The `semi_join_compatibility` and `anti_join_compatibility` descriptions still said that non-preserved side columns cause `UNKNOWN_IDENTIFIER`. Since the introduction of `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED`, explicit references to the skipped side (qualified columns, qualified wildcards, `USING` columns, and fully qualified `db.table.column`) raise the dedicated error, even in statically-dead branches, while only an unqualified miss still falls back to `UNKNOWN_IDENTIFIER`. Update both descriptions to match the shipped behavior. Co-authored-by: Cursor --- src/Core/Settings.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index c2ff1f7fbb0f..b6cff58d5ed6 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7850,7 +7850,7 @@ Allow to add compound identifiers to nested. This is a compatibility setting bec When enabled, this setting makes the analyzer restrict `SEMI` JOIN column access to the preserved side in accordance with the SQL standard. For `LEFT SEMI JOIN` only left table columns are accessible, for `RIGHT SEMI JOIN` only right table columns. This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. -Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. +An explicit reference to a non-preserved side column raises the `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED` exception. This covers qualified references such as `t2.b`, qualified wildcards like `t2.*`, `USING` columns like `d.id`, and fully qualified references like `db.table.column`, and it is enforced even inside statically-dead branches such as `if(false, t2.b, 42)`. An unqualified identifier that does not match any accessible column still falls back to the generic `UNKNOWN_IDENTIFIER` exception. The `JOIN ON` expression of the same `JOIN` can access both sides regardless of this setting. When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. )", 0) \ @@ -7858,7 +7858,7 @@ When disabled, legacy behavior keeps columns from both sides accessible, and `SE When enabled, this setting makes the analyzer restrict `ANTI` JOIN column access to the preserved side in accordance with the SQL standard. For `LEFT ANTI JOIN` only left table columns are accessible, for `RIGHT ANTI JOIN` only right table columns. This applies to expressions resolved from the joined result, such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY` clauses, including qualified wildcards like `t1.*`. -Non-preserved side columns cause `UNKNOWN_IDENTIFIER` exception. +An explicit reference to a non-preserved side column raises the `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED` exception. This covers qualified references such as `t2.b`, qualified wildcards like `t2.*`, `USING` columns like `d.id`, and fully qualified references like `db.table.column`, and it is enforced even inside statically-dead branches such as `if(false, t2.b, 42)`. An unqualified identifier that does not match any accessible column still falls back to the generic `UNKNOWN_IDENTIFIER` exception. The `JOIN ON` expression of the same `JOIN` can access both sides regardless of this setting. When disabled, legacy behavior keeps columns from both sides accessible, and `SELECT *` returns columns from both sides. )", 0) \ From 8a98202f37645dc7e711580f1b2c6808d9696dd9 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 30 Jul 2026 02:21:26 +0000 Subject: [PATCH 032/272] Fix `readability-duplicate-include` in `QueryAnalyzer.cpp` The `master` merges left two `#include ` lines in `src/Analyzer/Resolve/QueryAnalyzer.cpp`: one from `master` (before `Core/Settings.h`) and one added by this branch. `Build (arm_tidy)` failed with: src/Analyzer/Resolve/QueryAnalyzer.cpp:46:1: error: duplicate include [readability-duplicate-include,-warnings-as-errors] Remove the branch's copy; `master`'s include already covers `SCOPE_EXIT`. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=96662&sha=c141370c54b36d10129181e904eae703c5e04d78&name_0=PR&name_1=Build%20%28arm_tidy%29 --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 023d8415d2a2..182374a8c31c 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -43,8 +43,6 @@ #include #include -#include - #include #include #include From baa3b558087b51b2144ca6494da64e6969910362 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Fri, 31 Jul 2026 16:28:15 +0530 Subject: [PATCH 033/272] Reject prefix-wildcard patterns in row policy and SHOW POLICY parsers Extends the fix for issue #104864 to row policies and SHOW POLICY queries. RowPolicyName cannot represent prefix wildcards like `db*.*` or `table*`, so silently accepting them would narrow the policy scope and leave prefix-matched objects unprotected. CREATE/ALTER/DROP ROW POLICY and SHOW POLICIES/SHOW CREATE POLICY now reject such patterns at parse time with SYNTAX_ERROR. Valid patterns like `db.*`, `*.*`, and exact table names continue to work. Closes: https://github.com/ClickHouse/ClickHouse/issues/104864 --- CHANGELOG.md | 1 + src/Parsers/Access/ParserRowPolicyName.cpp | 11 ++- .../Access/ParserShowAccessEntitiesQuery.cpp | 10 ++- .../ParserShowCreateAccessEntityQuery.cpp | 10 ++- ...04657_row_policy_wildcard_syntax.reference | 8 +++ .../04657_row_policy_wildcard_syntax.sql | 72 +++++++++++++++++++ 6 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference create mode 100644 tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3ee650e831..2653483292f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -356,6 +356,7 @@ * Start the Prometheus endpoint (for metrics-only configurations) and asynchronous metrics collection before tables are loaded, so metrics are visible during the potentially long metadata loading phase. [#108402](https://github.com/ClickHouse/ClickHouse/pull/108402) ([Christoph Wurm](https://github.com/cwurm)). #### Bug Fix (user-visible misbehavior in an official stable release) +* Fixed row policy parsing to reject prefix wildcard patterns (`db*.*`, `table*`) instead of silently accepting them. `RowPolicyName` cannot represent such patterns — it carries only exact database/table names or the database-wide `db.*` form — so silently accepting `db*.*` and narrowing it to `db.*` would leave prefix-matched objects without the intended row policy. Queries like `CREATE ROW POLICY p ON db*.* ...`, `SHOW POLICIES ON table* ...`, and their `ALTER`/`DROP` equivalents now produce a syntax error. Valid forms such as `db.*`, `*.*`, and exact table names continue to work. Closes [#104864](https://github.com/ClickHouse/ClickHouse/issues/104864). ([Aditya Kumar](https://github.com/Aditya-kumar121001)). * Fix `TRUNCATE TABLE` and `DROP PARTITION` failing on tables with many deduplication blocks, where removing them in a single ZooKeeper request could exceed the default 1 MB `jute.maxbuffer` limit. [#105991](https://github.com/ClickHouse/ClickHouse/pull/105991) ([Clayton McClure](https://github.com/cmcclure-twilio)). This only affects you if you use Apache ZooKeeper (not recommended) instead of ClickHouse Keeper. * Fix the usage of qualified column names (`database.table.column`) in the `WHERE` clause of mutations, such as `DELETE FROM` and `ALTER TABLE ... UPDATE/DELETE`, over `MergeTree`-family tables; previously such queries failed with a missing-columns error. Closes [#71760](https://github.com/ClickHouse/ClickHouse/issues/71760). [#109491](https://github.com/ClickHouse/ClickHouse/pull/109491) ([Mikhail Artemenko](https://github.com/Michicosun)). * Fix the query result cache for PromQL queries. The `promql` dialect bypassed the non-deterministic-function check and could serve stale results anchored at `now()`; the Prometheus HTTP API (`/api/v1/query`, `/api/v1/query_range`) never stored cache entries at all. [#110887](https://github.com/ClickHouse/ClickHouse/pull/110887) ([Nikita Mikhaylov](https://github.com/nikitamikhaylov)). diff --git a/src/Parsers/Access/ParserRowPolicyName.cpp b/src/Parsers/Access/ParserRowPolicyName.cpp index 54d987726969..179624dad6bc 100644 --- a/src/Parsers/Access/ParserRowPolicyName.cpp +++ b/src/Parsers/Access/ParserRowPolicyName.cpp @@ -30,8 +30,15 @@ namespace bool wildcard = false; bool default_database = false; - if (!parseDatabaseAndTableNameOrAsterisks(pos, expected, res_database, res_table_name, wildcard, default_database) - || (res_database.empty() && res_table_name.empty() && !default_database)) + if (!parseDatabaseAndTableNameOrAsterisks(pos, expected, res_database, res_table_name, wildcard, default_database)) + return false; + + /// RowPolicyName only carries exact database/table names or a database-wide '*' + /// (represented by an empty table name). A prefix wildcard like `db*.*` or + /// `table*` cannot be represented here, and silently narrowing it to the + /// non-wildcard form would leave prefix-matched objects without the intended + /// row policy. Reject it explicitly instead. + if (wildcard) return false; if (res_table_name.empty()) diff --git a/src/Parsers/Access/ParserShowAccessEntitiesQuery.cpp b/src/Parsers/Access/ParserShowAccessEntitiesQuery.cpp index 0cfaae9c4b6f..316a6070e2c3 100644 --- a/src/Parsers/Access/ParserShowAccessEntitiesQuery.cpp +++ b/src/Parsers/Access/ParserShowAccessEntitiesQuery.cpp @@ -30,8 +30,14 @@ namespace { return IParserBase::wrapParseImpl(pos, [&] { - return ParserKeyword{Keyword::ON}.ignore(pos, expected) - && parseDatabaseAndTableNameOrAsterisks(pos, expected, database, table, wildcard, default_database); + if (!ParserKeyword{Keyword::ON}.ignore(pos, expected) + || !parseDatabaseAndTableNameOrAsterisks(pos, expected, database, table, wildcard, default_database)) + return false; + + /// A prefix wildcard like `db*.*` or `table*` cannot be represented by the + /// (database, table) pair this query type carries; reject it explicitly + /// instead of silently narrowing it to the non-wildcard form. + return !wildcard; }); } } diff --git a/src/Parsers/Access/ParserShowCreateAccessEntityQuery.cpp b/src/Parsers/Access/ParserShowCreateAccessEntityQuery.cpp index 46ec1ed96233..374bc617f9b2 100644 --- a/src/Parsers/Access/ParserShowCreateAccessEntityQuery.cpp +++ b/src/Parsers/Access/ParserShowCreateAccessEntityQuery.cpp @@ -53,8 +53,14 @@ namespace { return IParserBase::wrapParseImpl(pos, [&] { - return ParserKeyword{Keyword::ON}.ignore(pos, expected) - && parseDatabaseAndTableNameOrAsterisks(pos, expected, database, table, wildcard, default_database); + if (!ParserKeyword{Keyword::ON}.ignore(pos, expected) + || !parseDatabaseAndTableNameOrAsterisks(pos, expected, database, table, wildcard, default_database)) + return false; + + /// A prefix wildcard like `db*.*` or `table*` cannot be represented by the + /// (database, table) pair this query type carries; reject it explicitly + /// instead of silently narrowing it to the non-wildcard form. + return !wildcard; }); } } diff --git a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference new file mode 100644 index 000000000000..6d91674b9e24 --- /dev/null +++ b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference @@ -0,0 +1,8 @@ +mydb mytable policy_04657 PERMISSIVE default SELECT 1 +CREATE POLICY policy_04657 ON mydb.mytable AS PERMISSIVE FOR SELECT USING 1 TO default +mydb policy_04657 PERMISSIVE default SELECT 1 +CREATE POLICY policy_04657 ON mydb.* AS PERMISSIVE FOR SELECT USING 1 TO default + policy_04657 PERMISSIVE default SELECT 1 +CREATE POLICY policy_04657 ON *.* AS PERMISSIVE FOR SELECT USING 1 TO default + mytable policy_04657 PERMISSIVE default SELECT 1 +CREATE POLICY policy_04657 ON mytable AS PERMISSIVE FOR SELECT USING 1 TO default diff --git a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql new file mode 100644 index 000000000000..5ee886ace358 --- /dev/null +++ b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql @@ -0,0 +1,72 @@ +-- Tags: no-parallel +-- Regression test for https://github.com/ClickHouse/ClickHouse/issues/104864 +-- Follow-up to the GRANT fix: row policies must also reject prefix wildcards +-- like db*.* or table* because RowPolicyName cannot represent them. + +-- ============================================================ +-- PART 1: Valid patterns -- must still work correctly +-- ============================================================ + +DROP ROW POLICY IF EXISTS policy_04657 ON mydb.mytable; +DROP ROW POLICY IF EXISTS policy_04657 ON mydb.*; +DROP ROW POLICY IF EXISTS policy_04657 ON *.*; +DROP ROW POLICY IF EXISTS policy_04657 ON mytable; + +-- Valid: specific db, specific table +CREATE ROW POLICY policy_04657 ON mydb.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; +SHOW POLICIES ON mydb.mytable; +SHOW CREATE POLICY policy_04657 ON mydb.mytable; +DROP ROW POLICY policy_04657 ON mydb.mytable; + +-- Valid: specific db, all tables +CREATE ROW POLICY policy_04657 ON mydb.* AS PERMISSIVE FOR SELECT USING 1 TO default; +SHOW POLICIES ON mydb.*; +SHOW CREATE POLICY policy_04657 ON mydb.*; +DROP ROW POLICY policy_04657 ON mydb.*; + +-- Valid: all dbs, all tables +CREATE ROW POLICY policy_04657 ON *.* AS PERMISSIVE FOR SELECT USING 1 TO default; +SHOW POLICIES ON *.*; +SHOW CREATE POLICY policy_04657 ON *.*; +DROP ROW POLICY policy_04657 ON *.*; + +-- Valid: table in default database +CREATE ROW POLICY policy_04657 ON mytable AS PERMISSIVE FOR SELECT USING 1 TO default; +SHOW POLICIES ON mytable; +SHOW CREATE POLICY policy_04657 ON mytable; +DROP ROW POLICY policy_04657 ON mytable; + +-- ============================================================ +-- PART 2: Invalid patterns -- must produce SYNTAX_ERROR +-- ============================================================ + +-- Database prefix wildcard + all tables: RowPolicyName cannot represent db*.* +CREATE ROW POLICY policy_04657 ON mydb*.* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } + +-- Database prefix wildcard + specific table: also invalid +CREATE ROW POLICY policy_04657 ON mydb*.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } + +-- Database prefix wildcard + table prefix wildcard: also invalid +CREATE ROW POLICY policy_04657 ON mydb*.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } + +-- Table prefix wildcard: RowPolicyName cannot represent table* +CREATE ROW POLICY policy_04657 ON mydb.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } + +-- Table prefix wildcard in default database: also invalid +CREATE ROW POLICY policy_04657 ON mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } + +-- ALTER ROW POLICY with invalid patterns +ALTER ROW POLICY IF EXISTS policy_04657 ON mydb*.* TO default; -- { clientError SYNTAX_ERROR } +ALTER ROW POLICY IF EXISTS policy_04657 ON mytable* TO default; -- { clientError SYNTAX_ERROR } + +-- DROP ROW POLICY with invalid patterns +DROP ROW POLICY IF EXISTS policy_04657 ON mydb*.*; -- { clientError SYNTAX_ERROR } +DROP ROW POLICY IF EXISTS policy_04657 ON mytable*; -- { clientError SYNTAX_ERROR } + +-- SHOW POLICIES with invalid patterns +SHOW POLICIES ON mydb*.*; -- { clientError SYNTAX_ERROR } +SHOW POLICIES ON mytable*; -- { clientError SYNTAX_ERROR } + +-- SHOW CREATE POLICY with invalid patterns +SHOW CREATE POLICY policy_04657 ON mydb*.*; -- { clientError SYNTAX_ERROR } +SHOW CREATE POLICY policy_04657 ON mytable*; -- { clientError SYNTAX_ERROR } From 147cd63881e969d4450fa97274ac44267f0881d9 Mon Sep 17 00:00:00 2001 From: zhangyifan27 Date: Fri, 31 Jul 2026 19:45:41 +0800 Subject: [PATCH 034/272] Recognize materialized CTE qualifiers in SEMI/ANTI JOIN access check `qualifierBindsToJoinSubtree` mirrored the qualifier binding rules of normal table-expression resolution for aliases, bare table names, and `database.table`, but not for materialized-CTE names accepted via `TableNode::isMaterializedCTE`. When a materialized CTE sat on the non-preserved side of a SEMI/ANTI JOIN, an explicit `cte.column` reference never set `denied_qualified_access`: a live reference degraded to `UNKNOWN_IDENTIFIER`, and a dead-branch `if(false, cte.column, 42)` was silently folded to `42` instead of raising `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED`. Add the materialized-CTE binding case, matching `tryResolveIdentifierFromTableExpression`, plus regressions for the live-reference and dead-branch shapes and a preserved-side control. Co-authored-by: Cursor --- src/Analyzer/Resolve/IdentifierResolver.cpp | 10 ++++++++-- ...913_semi_anti_join_column_access.reference | 1 + .../03913_semi_anti_join_column_access.sql | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index 3069ebebe3e2..f01e7a54dff4 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1181,11 +1181,13 @@ void SemiAntiJoinSideChecker::throwIfTableAccessDenied( /** Check whether the leading table qualifier of `identifier` binds to some table expression * inside `join_tree_node`. This mirrors the qualifier binding rules used by normal - * table-expression resolution (see `tryBindIdentifierToTableExpression`): - * - a single-part qualifier matches a table alias or a table name; + * table-expression resolution (see `tryResolveIdentifierFromTableExpression`): + * - a single-part qualifier matches a table alias, a table name, or a materialized-CTE name; * - a two-part qualifier matches a fully qualified `database.table` reference. * Recognizing the two-part form is required so that fully qualified references such as * `db.table.column` are correctly attributed to the skipped side of a SEMI/ANTI JOIN. + * The materialized-CTE form is required so that references like `cte.column` to a CTE on the + * skipped side are attributed to it instead of degrading to `UNKNOWN_IDENTIFIER`. */ static bool qualifierBindsToJoinSubtree( const TableExpressionNodePtr & join_tree_node, @@ -1200,6 +1202,10 @@ static bool qualifierBindsToJoinSubtree( if (join_tree_node->hasAlias() && join_tree_node->getAlias() == path_start) return true; + if (const auto * table_node = join_tree_node->as(); + table_node && table_node->isMaterializedCTE() && path_start == table_node->getMaterializedCTE()->cte_name) + return true; + switch (join_tree_node->getNodeType()) { case QueryTreeNodeType::JOIN: diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 4c0638a9c100..7d8a4124c728 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -38,3 +38,4 @@ 1 1 42 +2 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 38fadf733643..fed8a752fe92 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -303,3 +303,23 @@ SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l.a, 42) FROM { SELECT if(true, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } DROP TABLE semi_anti_qual_l; DROP TABLE semi_anti_qual_r; + +-- Materialized CTEs must bind as qualifiers here just like they do in normal table-expression +-- resolution (`TableNode::isMaterializedCTE`). When a materialized CTE sits on the non-preserved +-- side of a SEMI/ANTI JOIN, an explicit `cte.column` reference must be attributed to it and raise +-- SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED. Without recognizing the CTE name, a live reference would +-- degrade to UNKNOWN_IDENTIFIER and a dead-branch `if(false, cte.column, 42)` would silently fold +-- to 42 instead of raising the access error. +SET enable_materialized_cte = 1; +-- Live reference to the non-preserved materialized CTE: denied. +WITH cte_r AS MATERIALIZED (SELECT 2 AS b) +SELECT cte_r.b FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN cte_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- Dead-branch reference to the non-preserved materialized CTE: still denied, not folded to 42. +WITH cte_r AS MATERIALIZED (SELECT 2 AS b) +SELECT if(false, cte_r.b, 42) FROM (SELECT 1 AS a) t1 LEFT SEMI JOIN cte_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- Same shape with LEFT ANTI JOIN. +WITH cte_r AS MATERIALIZED (SELECT 2 AS b) +SELECT if(false, cte_r.b, 42) FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN cte_r ON false; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- A materialized CTE on the preserved (right) side of a RIGHT SEMI JOIN stays accessible. +WITH cte_r AS MATERIALIZED (SELECT 2 AS b) +SELECT cte_r.b FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN cte_r ON true; From 5e24a56909ed3f2e4ee3e9c93b7fe91f5e97a925 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Fri, 31 Jul 2026 18:31:14 +0530 Subject: [PATCH 035/272] Fix row policy wildcard tests: correct error annotations and test isolation - 04402: fix clientError->serverError annotations (GRANT parser rejects prefix-wildcards server-side, not client-side) - 04657: scope test to dedicated database, use currentDatabase()-relative patterns, drop non-representative *.* case (resolves to current-db-all-tables, not global, in ClickHouse row policy semantics) --- .../04402_wildcard_grant_db_table_syntax.sql | 6 +- ...04657_row_policy_wildcard_syntax.reference | 14 ++-- .../04657_row_policy_wildcard_syntax.sql | 69 +++++++++---------- 3 files changed, 43 insertions(+), 46 deletions(-) diff --git a/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql index 6e3d45d8ad9f..d0c6f936ca3e 100644 --- a/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql +++ b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql @@ -33,10 +33,10 @@ DROP USER user_04402; -- BUG WAS HERE: db*.table was silently interpreted as db.table* -- Now it must be rejected as invalid/ambiguous syntax -GRANT SELECT ON db*.table TO user_04402; -- { clientError SYNTAX_ERROR } +GRANT SELECT ON db*.table TO user_04402; -- { serverError SYNTAX_ERROR } -- db wildcard + specific table name = also invalid -GRANT SELECT ON db*.mytable TO user_04402; -- { clientError SYNTAX_ERROR } +GRANT SELECT ON db*.mytable TO user_04402; -- { serverError SYNTAX_ERROR } -- db wildcard + table wildcard combined = also invalid -GRANT SELECT ON mydb*.mytable* TO user_04402; -- { clientError SYNTAX_ERROR } +GRANT SELECT ON mydb*.mytable* TO user_04402; -- { serverError SYNTAX_ERROR } diff --git a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference index 6d91674b9e24..9e39f0d8a5d9 100644 --- a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference +++ b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.reference @@ -1,8 +1,6 @@ -mydb mytable policy_04657 PERMISSIVE default SELECT 1 -CREATE POLICY policy_04657 ON mydb.mytable AS PERMISSIVE FOR SELECT USING 1 TO default -mydb policy_04657 PERMISSIVE default SELECT 1 -CREATE POLICY policy_04657 ON mydb.* AS PERMISSIVE FOR SELECT USING 1 TO default - policy_04657 PERMISSIVE default SELECT 1 -CREATE POLICY policy_04657 ON *.* AS PERMISSIVE FOR SELECT USING 1 TO default - mytable policy_04657 PERMISSIVE default SELECT 1 -CREATE POLICY policy_04657 ON mytable AS PERMISSIVE FOR SELECT USING 1 TO default +policy_04657 db_04657 mytable 0 0 ['default'] [] +CREATE ROW POLICY policy_04657 ON db_04657.mytable FOR SELECT USING 1 TO default +policy_04657 db_04657 0 0 ['default'] [] +CREATE ROW POLICY policy_04657 ON db_04657.* FOR SELECT USING 1 TO default +policy_04657 db_04657 mytable 0 0 ['default'] [] +CREATE ROW POLICY policy_04657 ON db_04657.mytable FOR SELECT USING 1 TO default diff --git a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql index 5ee886ace358..f31ae5ba083c 100644 --- a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql +++ b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql @@ -3,36 +3,32 @@ -- Follow-up to the GRANT fix: row policies must also reject prefix wildcards -- like db*.* or table* because RowPolicyName cannot represent them. +DROP ROW POLICY IF EXISTS policy_04657 ON db_04657.mytable; +DROP ROW POLICY IF EXISTS policy_04657 ON db_04657.*; +DROP DATABASE IF EXISTS db_04657; + +CREATE DATABASE db_04657; +USE db_04657; + -- ============================================================ -- PART 1: Valid patterns -- must still work correctly -- ============================================================ -DROP ROW POLICY IF EXISTS policy_04657 ON mydb.mytable; -DROP ROW POLICY IF EXISTS policy_04657 ON mydb.*; -DROP ROW POLICY IF EXISTS policy_04657 ON *.*; -DROP ROW POLICY IF EXISTS policy_04657 ON mytable; - -- Valid: specific db, specific table -CREATE ROW POLICY policy_04657 ON mydb.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; -SHOW POLICIES ON mydb.mytable; -SHOW CREATE POLICY policy_04657 ON mydb.mytable; -DROP ROW POLICY policy_04657 ON mydb.mytable; +CREATE ROW POLICY policy_04657 ON db_04657.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; +SELECT short_name, database, table, is_restrictive, apply_to_all, apply_to_list, apply_to_except FROM system.row_policies WHERE database = 'db_04657' AND table = 'mytable' AND short_name = 'policy_04657' ORDER BY short_name, database, table; +SHOW CREATE POLICY policy_04657 ON db_04657.mytable; +DROP ROW POLICY policy_04657 ON db_04657.mytable; -- Valid: specific db, all tables -CREATE ROW POLICY policy_04657 ON mydb.* AS PERMISSIVE FOR SELECT USING 1 TO default; -SHOW POLICIES ON mydb.*; -SHOW CREATE POLICY policy_04657 ON mydb.*; -DROP ROW POLICY policy_04657 ON mydb.*; +CREATE ROW POLICY policy_04657 ON db_04657.* AS PERMISSIVE FOR SELECT USING 1 TO default; +SELECT short_name, database, table, is_restrictive, apply_to_all, apply_to_list, apply_to_except FROM system.row_policies WHERE database = 'db_04657' AND table = '' AND short_name = 'policy_04657' ORDER BY short_name, database, table; +SHOW CREATE POLICY policy_04657 ON db_04657.*; +DROP ROW POLICY policy_04657 ON db_04657.*; --- Valid: all dbs, all tables -CREATE ROW POLICY policy_04657 ON *.* AS PERMISSIVE FOR SELECT USING 1 TO default; -SHOW POLICIES ON *.*; -SHOW CREATE POLICY policy_04657 ON *.*; -DROP ROW POLICY policy_04657 ON *.*; - --- Valid: table in default database +-- Valid: table in current database CREATE ROW POLICY policy_04657 ON mytable AS PERMISSIVE FOR SELECT USING 1 TO default; -SHOW POLICIES ON mytable; +SELECT short_name, database, table, is_restrictive, apply_to_all, apply_to_list, apply_to_except FROM system.row_policies WHERE database = 'db_04657' AND table = 'mytable' AND short_name = 'policy_04657' ORDER BY short_name, database, table; SHOW CREATE POLICY policy_04657 ON mytable; DROP ROW POLICY policy_04657 ON mytable; @@ -41,32 +37,35 @@ DROP ROW POLICY policy_04657 ON mytable; -- ============================================================ -- Database prefix wildcard + all tables: RowPolicyName cannot represent db*.* -CREATE ROW POLICY policy_04657 ON mydb*.* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb*.* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } -- Database prefix wildcard + specific table: also invalid -CREATE ROW POLICY policy_04657 ON mydb*.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb*.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } -- Database prefix wildcard + table prefix wildcard: also invalid -CREATE ROW POLICY policy_04657 ON mydb*.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb*.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } -- Table prefix wildcard: RowPolicyName cannot represent table* -CREATE ROW POLICY policy_04657 ON mydb.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } --- Table prefix wildcard in default database: also invalid -CREATE ROW POLICY policy_04657 ON mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } +-- Table prefix wildcard in current database: also invalid +CREATE ROW POLICY policy_04657 ON mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } -- ALTER ROW POLICY with invalid patterns -ALTER ROW POLICY IF EXISTS policy_04657 ON mydb*.* TO default; -- { clientError SYNTAX_ERROR } -ALTER ROW POLICY IF EXISTS policy_04657 ON mytable* TO default; -- { clientError SYNTAX_ERROR } +ALTER ROW POLICY IF EXISTS policy_04657 ON mydb*.* TO default; -- { serverError SYNTAX_ERROR } +ALTER ROW POLICY IF EXISTS policy_04657 ON mytable* TO default; -- { serverError SYNTAX_ERROR } -- DROP ROW POLICY with invalid patterns -DROP ROW POLICY IF EXISTS policy_04657 ON mydb*.*; -- { clientError SYNTAX_ERROR } -DROP ROW POLICY IF EXISTS policy_04657 ON mytable*; -- { clientError SYNTAX_ERROR } +DROP ROW POLICY IF EXISTS policy_04657 ON mydb*.*; -- { serverError SYNTAX_ERROR } +DROP ROW POLICY IF EXISTS policy_04657 ON mytable*; -- { serverError SYNTAX_ERROR } -- SHOW POLICIES with invalid patterns -SHOW POLICIES ON mydb*.*; -- { clientError SYNTAX_ERROR } -SHOW POLICIES ON mytable*; -- { clientError SYNTAX_ERROR } +SHOW POLICIES ON mydb*.*; -- { serverError SYNTAX_ERROR } +SHOW POLICIES ON mytable*; -- { serverError SYNTAX_ERROR } -- SHOW CREATE POLICY with invalid patterns -SHOW CREATE POLICY policy_04657 ON mydb*.*; -- { clientError SYNTAX_ERROR } -SHOW CREATE POLICY policy_04657 ON mytable*; -- { clientError SYNTAX_ERROR } +SHOW CREATE POLICY policy_04657 ON mydb*.*; -- { serverError SYNTAX_ERROR } +SHOW CREATE POLICY policy_04657 ON mytable*; -- { serverError SYNTAX_ERROR } + +USE default; +DROP DATABASE db_04657; From dc7ffc5300346a70e779a70d5767b93de8349eea Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Sat, 1 Aug 2026 09:47:09 +0530 Subject: [PATCH 036/272] Fix 04402_wildcard_grant_db_table_syntax: use clientError instead of serverError for SYNTAX_ERROR The clickhouse-client parses queries client-side before sending them to the server (documented in src/Client/TestHint.h). Since the parser fix in parseDatabaseAndTableName.cpp rejects db*.table patterns during parsing, and this parsing happens on the client, the SYNTAX_ERROR is thrown as a client error, not a server error. Changed error annotations from serverError to clientError for all three invalid wildcard patterns in PART 2 of the test. Co-Authored-By: Claude Sonnet 4.5 --- .../0_stateless/04402_wildcard_grant_db_table_syntax.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql index d0c6f936ca3e..6e3d45d8ad9f 100644 --- a/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql +++ b/tests/queries/0_stateless/04402_wildcard_grant_db_table_syntax.sql @@ -33,10 +33,10 @@ DROP USER user_04402; -- BUG WAS HERE: db*.table was silently interpreted as db.table* -- Now it must be rejected as invalid/ambiguous syntax -GRANT SELECT ON db*.table TO user_04402; -- { serverError SYNTAX_ERROR } +GRANT SELECT ON db*.table TO user_04402; -- { clientError SYNTAX_ERROR } -- db wildcard + specific table name = also invalid -GRANT SELECT ON db*.mytable TO user_04402; -- { serverError SYNTAX_ERROR } +GRANT SELECT ON db*.mytable TO user_04402; -- { clientError SYNTAX_ERROR } -- db wildcard + table wildcard combined = also invalid -GRANT SELECT ON mydb*.mytable* TO user_04402; -- { serverError SYNTAX_ERROR } +GRANT SELECT ON mydb*.mytable* TO user_04402; -- { clientError SYNTAX_ERROR } From a65e8973f23e30fa5b58d76ca9f9a31f49142617 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Sat, 1 Aug 2026 10:23:01 +0530 Subject: [PATCH 037/272] Fix 04657_row_policy_wildcard_syntax: use clientError instead of serverError for SYNTAX_ERROR --- .../04657_row_policy_wildcard_syntax.sql | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql index f31ae5ba083c..f199dae235bb 100644 --- a/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql +++ b/tests/queries/0_stateless/04657_row_policy_wildcard_syntax.sql @@ -37,35 +37,35 @@ DROP ROW POLICY policy_04657 ON mytable; -- ============================================================ -- Database prefix wildcard + all tables: RowPolicyName cannot represent db*.* -CREATE ROW POLICY policy_04657 ON mydb*.* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb*.* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } -- Database prefix wildcard + specific table: also invalid -CREATE ROW POLICY policy_04657 ON mydb*.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb*.mytable AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } -- Database prefix wildcard + table prefix wildcard: also invalid -CREATE ROW POLICY policy_04657 ON mydb*.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb*.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } -- Table prefix wildcard: RowPolicyName cannot represent table* -CREATE ROW POLICY policy_04657 ON mydb.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mydb.mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } -- Table prefix wildcard in current database: also invalid -CREATE ROW POLICY policy_04657 ON mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { serverError SYNTAX_ERROR } +CREATE ROW POLICY policy_04657 ON mytable* AS PERMISSIVE FOR SELECT USING 1 TO default; -- { clientError SYNTAX_ERROR } -- ALTER ROW POLICY with invalid patterns -ALTER ROW POLICY IF EXISTS policy_04657 ON mydb*.* TO default; -- { serverError SYNTAX_ERROR } -ALTER ROW POLICY IF EXISTS policy_04657 ON mytable* TO default; -- { serverError SYNTAX_ERROR } +ALTER ROW POLICY IF EXISTS policy_04657 ON mydb*.* TO default; -- { clientError SYNTAX_ERROR } +ALTER ROW POLICY IF EXISTS policy_04657 ON mytable* TO default; -- { clientError SYNTAX_ERROR } -- DROP ROW POLICY with invalid patterns -DROP ROW POLICY IF EXISTS policy_04657 ON mydb*.*; -- { serverError SYNTAX_ERROR } -DROP ROW POLICY IF EXISTS policy_04657 ON mytable*; -- { serverError SYNTAX_ERROR } +DROP ROW POLICY IF EXISTS policy_04657 ON mydb*.*; -- { clientError SYNTAX_ERROR } +DROP ROW POLICY IF EXISTS policy_04657 ON mytable*; -- { clientError SYNTAX_ERROR } -- SHOW POLICIES with invalid patterns -SHOW POLICIES ON mydb*.*; -- { serverError SYNTAX_ERROR } -SHOW POLICIES ON mytable*; -- { serverError SYNTAX_ERROR } +SHOW POLICIES ON mydb*.*; -- { clientError SYNTAX_ERROR } +SHOW POLICIES ON mytable*; -- { clientError SYNTAX_ERROR } -- SHOW CREATE POLICY with invalid patterns -SHOW CREATE POLICY policy_04657 ON mydb*.*; -- { serverError SYNTAX_ERROR } -SHOW CREATE POLICY policy_04657 ON mytable*; -- { serverError SYNTAX_ERROR } +SHOW CREATE POLICY policy_04657 ON mydb*.*; -- { clientError SYNTAX_ERROR } +SHOW CREATE POLICY policy_04657 ON mytable*; -- { clientError SYNTAX_ERROR } USE default; DROP DATABASE db_04657; From 42a675d6c11942377558bcd032cd2812435c63ac Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Tue, 18 Aug 2026 16:00:09 +0000 Subject: [PATCH 038/272] Fix `QueryAnalyzer` settings declaration --- src/Analyzer/Resolve/QueryAnalyzer.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index a1ff183ef6b0..e6e50dc5432b 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -84,6 +84,7 @@ namespace Setting { extern const SettingsBool aggregate_functions_null_for_empty; extern const SettingsBool analyzer_compatibility_allow_non_aggregate_in_having; + extern const SettingsBool analyzer_compatibility_prefer_alias_over_subcolumn; extern const SettingsBool enable_streaming_queries; extern const SettingsBool analyzer_compatibility_join_using_top_level_identifier; extern const SettingsBool analyzer_compatibility_multiple_joins_qualify_column_names; From 85b2da342191de5c1a615256346d4b68668e22a9 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Wed, 19 Aug 2026 05:00:43 +0000 Subject: [PATCH 039/272] Keep the state version in views and copies of ColumnAggregateFunction A view of a `ColumnAggregateFunction` (the result of a filter, a permutation - e.g. a sort - or an index operation) and a plain copy of it dropped the state `version` and the type string. The version affects how the states are serialized: `groupArray` over a state column serializes the states into its own state and deserializes them back at the version of the result column, so a query like SELECT arraySlice(groupArray(x), 1, 1) FROM (SELECT medianDeterministicState(number, number) AS x FROM numbers(10) GROUP BY number ORDER BY number) failed with an exception (`Cannot read all data` / `Extra bytes found after deserializing aggregation state`) for any versioned aggregate function state: the states collected from the sorted (view) column were serialized at version 0, while the result column deserialized them at version 1. The test is added in the last commit of this pull request, together with the version 1 of the `uniq` state (the reference values depend on it). Co-Authored-By: Claude Fable 5 --- src/Columns/ColumnAggregateFunction.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Columns/ColumnAggregateFunction.cpp b/src/Columns/ColumnAggregateFunction.cpp index f4961ec20af5..975550a114f8 100644 --- a/src/Columns/ColumnAggregateFunction.cpp +++ b/src/Columns/ColumnAggregateFunction.cpp @@ -808,13 +808,18 @@ ColumnAggregateFunction::MutablePtr ColumnAggregateFunction::createView() const { auto res = create(func, concatArenas(foreign_arenas, my_arena)); res->src = getPtr(); + /// The view refers to the same states, so it must keep the state version: + /// the version affects how the states are serialized (e.g. in `groupArray` over a state column). + res->version = version; + res->type_string = type_string; return res; } ColumnAggregateFunction::ColumnAggregateFunction(const ColumnAggregateFunction & src_) : COWHelper, ColumnAggregateFunction>(src_), foreign_arenas(concatArenas(src_.foreign_arenas, src_.my_arena)), - func(src_.func), src(src_.getPtr()), data(src_.data.begin(), src_.data.end()) + func(src_.func), src(src_.getPtr()), data(src_.data.begin(), src_.data.end()), + type_string(src_.type_string), version(src_.version) { } From 7499b06c24357e77c59bb3fcb0160d623d61cba1 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Wed, 19 Aug 2026 05:00:56 +0000 Subject: [PATCH 040/272] Derive the state version from the negotiated revision in parallel block marshalling `ColumnBLOB::toBLOB` serialized the column with the state version resolved from the local type, while `NativeWriter::write` announces the type with the version derived from the negotiated revision. For a versioned aggregate function state (e.g. `quantileDeterministic`) the payload of a marshalled block was thus written at version 0 while the announced type said version 1, and the receiver lost sync inside the blob, e.g.: SELECT medianDeterministic(n, n) FROM remote('127.0.0.{1,2}', default, t) GROUP BY n % 2 SETTINGS enable_parallel_blocks_marshalling = 1, prefer_localhost_replica = 0 failed with an exception (`Skip degree in quantileDeterministic state is 120, which exceeds the maximum value of 31`). Derive the version in `toBLOB` exactly the same way `NativeWriter::write` does. The test is added in the last commit of this pull request, together with the version 1 of the `uniq` state (the reference values depend on it). Co-Authored-By: Claude Fable 5 --- src/Columns/ColumnBLOB.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Columns/ColumnBLOB.h b/src/Columns/ColumnBLOB.h index 0c2461e59b00..45936d03eb91 100644 --- a/src/Columns/ColumnBLOB.h +++ b/src/Columns/ColumnBLOB.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -108,6 +110,14 @@ public: { WriteBufferFromVector wbuf(blob); CompressedWriteBuffer compressed_buffer(wbuf, codec); + /// The type announced on the wire gets the state version of a versioned aggregate function + /// derived from the negotiated revision (see the comment in `NativeWriter::write`), and the + /// reader parses the payload according to the announced type. Derive the version the same way + /// here, or the payload would be written with the version resolved from the local type + /// and lose sync with the announcement. + bool include_version = client_revision >= DBMS_MIN_REVISION_WITH_AGGREGATE_FUNCTIONS_VERSIONING; + setVersionToAggregateFunctions( + wrapped_column.type, /* if_empty= */ client_revision == 0, include_version ? std::optional(client_revision) : std::nullopt); auto [serialization, _, column_to_write] = NativeWriter::getSerializationAndColumn(client_revision, wrapped_column); NativeWriter::writeData( *serialization, column_to_write, compressed_buffer, format_settings, 0, column_to_write->size(), client_revision); From fa37ee34a4f371bab473f1876014cae6f6ee4edc Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Wed, 19 Aug 2026 05:01:24 +0000 Subject: [PATCH 041/272] Avoid overflow in the uniq aggregate function at cardinalities above ten billion The `uniq` state kept only 32 bits of each hash, so beyond a few billion distinct values the estimate saturated, and above ~30 billion the correction formula computed `log` of a non-positive number, and the cast of the resulting infinity to an integer was undefined behavior that produced values like 18446743978444128518. The state now keeps full 64-bit hashes (state version 1 - the versioning scheme follows the one of `quantileDeterministic`, see 42cecd2acad and 3d1b63153df): - an unversioned type keeps the legacy byte layout, so all persisted data stays readable; - a fresh state spells version 1 out in its type, a fresh table gets it pinned at DDL time; - on the wire the version is derived from the negotiated revision, so an old peer receives the states with the hashes truncated to 32 bits - exactly the values it would have computed itself; - a state carrying only 32 bits of hash information (read from an old table or received from an old server) is kept in a compat mode, and merging a 64-bit state with it downgrades the former by truncation, because the missing bits cannot be recovered - the result matches the legacy computation bit for bit; - the legacy estimate formula is clamped, so a saturated legacy state now returns the saturation limit (about 9.5e10) instead of the garbage above. SELECT uniq(number) FROM numbers_mt(100000000000) now returns 100288510111 (+0.29%), and 1010107886346 (+1.0%) for a trillion, instead of 18446743978444128518. The cost: the stored hashes are twice as large, so a saturated state takes 1 MiB of memory and 512 KiB serialized (instead of 512 KiB / 256 KiB), and group-heavy queries with mid-sized groups are moderately slower due to the doubled memory traffic. Closes: https://github.com/ClickHouse/ClickHouse/issues/6078 Co-Authored-By: Claude Fable 5 --- .../interfaces/specs/NativeFormat.mdx | 3 +- .../interfaces/specs/NativeProtocol.mdx | 1 + .../AggregateFunctionUniq.cpp | 6 + .../AggregateFunctionUniq.h | 92 +++++- src/AggregateFunctions/UniquesHashSet.h | 281 +++++++++++++++--- .../tests/gtest_uniques_hash_set.cpp | 239 +++++++++++++++ src/Core/ProtocolDefines.h | 5 +- .../00973_uniq_non_associativity.reference | 4 +- ...ray_slice_of_aggregate_functions.reference | 2 +- .../01890_state_of_state.reference | 18 +- .../02418_aggregate_combinators.reference | 10 +- ..._combinators_with_over_statement.reference | 100 +++---- ...w_columns_called_from_clickhouse.reference | 2 +- .../03821_uniq_speedup_boundaries.reference | 2 +- .../03821_uniq_speedup_boundaries.sql | 4 +- ..._partition_by_aggregate_function_state.sql | 6 +- ..._uniq_state_version_64bit_hashes.reference | 12 + .../05023_uniq_state_version_64bit_hashes.sql | 59 ++++ ...ate_version_parallel_marshalling.reference | 3 + ...ate_state_version_parallel_marshalling.sql | 35 +++ ...te_state_version_in_column_views.reference | 4 + ...ggregate_state_version_in_column_views.sql | 28 ++ 22 files changed, 790 insertions(+), 126 deletions(-) create mode 100644 src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp create mode 100644 tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.reference create mode 100644 tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.sql create mode 100644 tests/queries/0_stateless/05024_aggregate_state_version_parallel_marshalling.reference create mode 100644 tests/queries/0_stateless/05024_aggregate_state_version_parallel_marshalling.sql create mode 100644 tests/queries/0_stateless/05025_aggregate_state_version_in_column_views.reference create mode 100644 tests/queries/0_stateless/05025_aggregate_state_version_in_column_views.sql diff --git a/docs/reference/interfaces/specs/NativeFormat.mdx b/docs/reference/interfaces/specs/NativeFormat.mdx index ae9f0acbf43c..e4530a83354f 100644 --- a/docs/reference/interfaces/specs/NativeFormat.mdx +++ b/docs/reference/interfaces/specs/NativeFormat.mdx @@ -312,6 +312,7 @@ Each feature sits behind a `DBMS_MIN_REVISION_WITH_*` threshold. The writer emit | V2 `Dynamic` / `JSON` serialization | `DBMS_MIN_REVISION_WITH_V2_DYNAMIC_AND_JSON_SERIALIZATION` | `54473` | `Dynamic` and `JSON`/`Object` use V1 serialization (with the `max_dynamic_*` parameter) instead of V2. | | Aggregate-function versioning | `DBMS_MIN_REVISION_WITH_AGGREGATE_FUNCTIONS_VERSIONING` | `54452` | `AggregateFunction` state is written without an embedded version. | | `skip_degree` in `quantileDeterministic` state | `DBMS_MIN_REVISION_WITH_QUANTILE_DETERMINISTIC_SKIP_DEGREE` | `54491` | `AggregateFunction(quantileDeterministic, ...)` states (and the `quantiles`/`median` spellings of the same function) are written at state version `0`, without the trailing `UInt8 skip_degree`. See the note under [type aliases](#type-aliases). | +| 64-bit hashes in `uniq` state | `DBMS_MIN_REVISION_WITH_UNIQ_STATE_VERSION_1` | `54492` | `AggregateFunction(uniq, ...)` states are written at state version `0`: no leading flags byte, and the stored hash values truncated to 32 bits. See the note under [type aliases](#type-aliases). | | `out_of_order_buckets` in `BlockInfo` | `DBMS_MIN_REVISION_WITH_OUT_OF_ORDER_BUCKETS_IN_AGGREGATION` | `54480` | `BlockInfo` field ID `3` is not written (see [BlockInfo](#blockinfo)). | | Parallel block marshalling (`DETACHED`) | `DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING` | `54478` | Columns are never wrapped in a `ColumnBLOB`; no `DETACHED` / `DETACHED_OVER_SPARSE` kinds appear (see [kind_stack](#kind-stack-and-sparse-encoding)). | | `DateTime(tz)` type parameter | `DBMS_MIN_REVISION_WITH_TIME_ZONE_PARAMETER_IN_DATETIME_DATA_TYPE` | `54337` | The timezone parameter is dropped from the `type` string — `DateTime('UTC')` is announced as bare `DateTime`. | @@ -1028,7 +1029,7 @@ So a `Point` column is decoded exactly as `Tuple(Float64, Float64)` (rendering a Two related types are **not** aliases. They are valid `Native` column types — a client can receive an `AggregateFunction` column from a `-State` combinator or distributed aggregation, for instance — but each carries its own specialized payload that is outside the scope of this page: -- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. +- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54492`: version `1` prepends a flags byte and stores full 8-byte hash values (or 4-byte ones when bit `0` of the flags marks a state that carries only 32 bits of hash information, inherited from version `0` data it was merged with), while a version `0` state keeps the legacy layout — no flags byte, hash values truncated to 4 bytes. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. - `QBit(T, N[, stride])` stores a vector with its bit planes transposed for vector-search workloads; its on-wire stream layout (group-major `FixedString` bit-plane streams, `element_size * (N / stride)` of them with an explicit `stride`) and its binary type encoding (tag `0x36`, or `0x37` `QBitWithStride` when `stride != N`) are documented on the [`QBit` data type page](/reference/data-types/qbit) and in the [binary type encoding](/reference/data-types/data-types-binary-encoding) reference, so a `Native` reader does not have to recover them from the C++ source. diff --git a/docs/reference/interfaces/specs/NativeProtocol.mdx b/docs/reference/interfaces/specs/NativeProtocol.mdx index 3b98307d84b2..1f5b8c43b7b3 100644 --- a/docs/reference/interfaces/specs/NativeProtocol.mdx +++ b/docs/reference/interfaces/specs/NativeProtocol.mdx @@ -155,6 +155,7 @@ When a feature is active, its fields **must** be present on the wire. The protoc | INTERSERVER_CURRENT_ROLES | 54488 | ClientInfo | Adds a trailing `current_roles` optional String list to ClientInfo (`[UInt8 present]` then, if `1`, `[VarUInt count][String]*count`), and, for inter-server queries, extends the `auth_hash` to cover the serialized list. Carries the initiator's active (enabled) role names so a secondary node scopes row policies the same way instead of falling back to the user's default roles. Only populated for inter-server queries and only when `push_external_roles_in_interserver_queries = 1`; external clients send `0` (absent). Required once negotiated version ≥ 54488 — omitting the presence byte desynchronizes the rest of the Query packet. | | HTTP_HANDLER_IN_CLIENT_INFO | 54490 | ClientInfo | Adds `http_handler_name` and `http_request_url` (both `String`) to the **HTTP** branch of ClientInfo, immediately after `http_referer`. They carry the name of the matched SQL-defined HTTP handler and the request URL, so that `currentHandler`/`currentRequestURL` and the `http_handler_name`/`http_request_url` `system.query_log` columns stay populated on remote shards for distributed handler queries. Written only when `query_interface = HTTP` (empty strings for a non-handler HTTP query); required once negotiated version ≥ 54490 — omitting it desynchronizes the rest of the Query packet. | | QUANTILE_DETERMINISTIC_SKIP_DEGREE | 54491 | Block (Column) | Bumps the state version of `AggregateFunction(quantileDeterministic, ...)` (and the `quantiles`/`median` spellings of it) from `0` to `1`, which appends a trailing `UInt8 skip_degree` to each serialized state. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states, so an older peer is unaffected. | +| UNIQ_STATE_VERSION_1 | 54492 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`, which stores full 64-bit hash values instead of ones truncated to 32 bits (fixing the overflow of the estimate at cardinalities above ten billion). A version `1` state starts with a flags byte: bit `0` set means the values carry only 32 bits of hash information (a state downgraded by contact with version `0` data) and are written as 4 bytes each; otherwise the values are 8 bytes each. The rest of the layout matches version `0`. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (with the stored hashes truncated to 32 bits), so an older peer is unaffected. | ## Packet envelope {#packet-envelope} diff --git a/src/AggregateFunctions/AggregateFunctionUniq.cpp b/src/AggregateFunctions/AggregateFunctionUniq.cpp index 8906c9d64fd7..518914cd93ea 100644 --- a/src/AggregateFunctions/AggregateFunctionUniq.cpp +++ b/src/AggregateFunctions/AggregateFunctionUniq.cpp @@ -237,6 +237,12 @@ For the calculation state, the function uses a sample of element hash values up This algorithm is very accurate and very efficient on the CPU. When the query contains several of these functions, using `uniq` is almost as fast as using other aggregate functions. +The state keeps 64-bit hashes of the values, so the estimate stays accurate for cardinalities of hundreds of billions. +States written by servers older than 26.8 (state version 0) kept only 32 bits of each hash, +which capped the number of distinguishable values at around ten billion. +Merging with such a state - as well as storing the state into a table column created before the upgrade, +whose type keeps state version 0 - degrades the calculation to the legacy 32-bit accuracy. + :::tip diff --git a/src/AggregateFunctions/AggregateFunctionUniq.h b/src/AggregateFunctions/AggregateFunctionUniq.h index ea6491955332..af5ef4f58c52 100644 --- a/src/AggregateFunctions/AggregateFunctionUniq.h +++ b/src/AggregateFunctions/AggregateFunctionUniq.h @@ -10,6 +10,7 @@ #include +#include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include namespace DB @@ -255,6 +257,16 @@ struct IsUniqExactSet> : std::true_type { }; +template +struct IsUniquesHashSet : std::false_type +{ +}; + +template +struct IsUniquesHashSet> : std::true_type +{ +}; + /** Hash function for uniq. */ template @@ -548,6 +560,7 @@ private: static constexpr size_t num_args = 1; static constexpr bool is_able_to_parallelize_merge = Data::is_able_to_parallelize_merge; static constexpr bool is_parallelize_merge_prepare_needed = Data::is_parallelize_merge_prepare_needed; + static constexpr bool is_uniques_hash_set = detail::IsUniquesHashSet::value; public: explicit AggregateFunctionUniq(const DataTypes & argument_types_) @@ -559,6 +572,34 @@ public: bool allocatesMemoryInArena() const override { return false; } + /// Version 1 of the `uniq` state keeps 64-bit hashes instead of 32-bit ones, + /// which fixes the overflow of the estimate at cardinalities above ten billion (issue #6078). + bool isVersioned() const override { return is_uniques_hash_set; } + + /// The default version - the one used when the type carries no explicit version - must stay 0, + /// so that unversioned `AggregateFunction(uniq, ...)` types in persisted data keep the byte + /// layout unversioned data always had. Version 1 applies only where it is spelled out + /// in the type name (see `getStateType`) or derived from the negotiated revision on the `Native` wire. + /// See the analogous comment for `quantileDeterministic` in AggregateFunctionQuantile.h for the full reasoning. + size_t getDefaultVersion() const override { return 0; } + + /// The state type spells the version out, so that the type name and the payload stay consistent + /// on every medium the state can travel through. + DataTypePtr getStateType() const override + { + if constexpr (is_uniques_hash_set) + return std::make_shared(this->shared_from_this(), this->argument_types, this->parameters, 1); + else + return IAggregateFunction::getStateType(); + } + + size_t getVersionFromRevision(size_t revision) const override + { + if (is_uniques_hash_set && revision >= DBMS_MIN_REVISION_WITH_UNIQ_STATE_VERSION_1) + return 1; + return 0; + } + /// ALWAYS_INLINE is required to have better code layout for uniqHLL12 function void ALWAYS_INLINE add(AggregateDataPtr __restrict place, const IColumn ** columns, size_t row_num, Arena *) const override { @@ -645,14 +686,20 @@ public: } } - void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional /* version */) const override + void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional version) const override { - this->data(place).set.write(buf); + if constexpr (is_uniques_hash_set) + this->data(place).set.write(buf, version.value_or(getDefaultVersion()) < 1); + else + this->data(place).set.write(buf); } - void deserialize(AggregateDataPtr __restrict place, ReadBuffer & buf, std::optional /* version */, Arena *) const override + void deserialize(AggregateDataPtr __restrict place, ReadBuffer & buf, std::optional version, Arena *) const override { - this->data(place).set.read(buf); + if constexpr (is_uniques_hash_set) + this->data(place).set.read(buf, version.value_or(getDefaultVersion()) < 1); + else + this->data(place).set.read(buf); } void insertResultInto(AggregateDataPtr __restrict place, IColumn & to, Arena *) const override @@ -674,6 +721,7 @@ private: static constexpr size_t is_able_to_parallelize_merge = Data::is_able_to_parallelize_merge; static constexpr size_t argument_is_tuple = Data::argument_is_tuple; + static constexpr bool is_uniques_hash_set = detail::IsUniquesHashSet::value; size_t num_args = 0; @@ -691,6 +739,28 @@ public: bool allocatesMemoryInArena() const override { return false; } + /// Version 1 of the `uniq` state keeps 64-bit hashes instead of 32-bit ones, + /// which fixes the overflow of the estimate at cardinalities above ten billion (issue #6078). + /// The versioning follows the same scheme as the single-argument `uniq` above, see the comments there. + bool isVersioned() const override { return is_uniques_hash_set; } + + size_t getDefaultVersion() const override { return 0; } + + DataTypePtr getStateType() const override + { + if constexpr (is_uniques_hash_set) + return std::make_shared(this->shared_from_this(), this->argument_types, this->parameters, 1); + else + return IAggregateFunction::getStateType(); + } + + size_t getVersionFromRevision(size_t revision) const override + { + if (is_uniques_hash_set && revision >= DBMS_MIN_REVISION_WITH_UNIQ_STATE_VERSION_1) + return 1; + return 0; + } + void add(AggregateDataPtr __restrict place, const IColumn ** columns, size_t row_num, Arena *) const override { detail::Adder::add(this->data(place), columns, num_args, row_num); @@ -752,14 +822,20 @@ public: } } - void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional /* version */) const override + void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional version) const override { - this->data(place).set.write(buf); + if constexpr (is_uniques_hash_set) + this->data(place).set.write(buf, version.value_or(getDefaultVersion()) < 1); + else + this->data(place).set.write(buf); } - void deserialize(AggregateDataPtr __restrict place, ReadBuffer & buf, std::optional /* version */, Arena *) const override + void deserialize(AggregateDataPtr __restrict place, ReadBuffer & buf, std::optional version, Arena *) const override { - this->data(place).set.read(buf); + if constexpr (is_uniques_hash_set) + this->data(place).set.read(buf, version.value_or(getDefaultVersion()) < 1); + else + this->data(place).set.read(buf); } void insertResultInto(AggregateDataPtr __restrict place, IColumn & to, Arena *) const override diff --git a/src/AggregateFunctions/UniquesHashSet.h b/src/AggregateFunctions/UniquesHashSet.h index c199fbb7ed92..c6d8f617c848 100644 --- a/src/AggregateFunctions/UniquesHashSet.h +++ b/src/AggregateFunctions/UniquesHashSet.h @@ -11,6 +11,7 @@ #include #include +#include /** Approximate calculation of anything, as usual, is constructed according to the following scheme: @@ -27,7 +28,7 @@ /** Very simple hash-set for approximate number of unique values. * Works like this: * - you can insert UInt64; - * - before insertion, first the hash function UInt64 -> UInt32 is calculated; + * - before insertion, first the hash function UInt64 -> UInt64 is calculated; * - the original value is not saved (lost); * - further all operations are made with these hashes; * - hash table is constructed according to the scheme: @@ -40,6 +41,23 @@ * - if the situation repeats, then only elements dividing by 4, etc., are taken. * - the size() method returns an approximate number of elements that have been inserted into the set; * - there are methods for quick reading and writing in binary and text form. + * + * Compatibility note. Historically the hashes were truncated to 32 bits, which capped the number + * of distinguishable elements at around ten billion: for larger cardinalities the estimate + * saturated and even resulted in undefined behavior in the correction formula (issue #6078). + * Now the full 64-bit hash is kept. States written in the legacy format (by old servers, + * or read from tables created before the change) identify elements by only the low 32 bits + * of the hash, and the discarded bits cannot be recovered. Such states are kept in a special + * "compat" mode: the stored values carry only 32 bits of information, and merging a 64-bit state + * with a compat state downgrades the former by truncating its hashes - the result is exactly + * the same as if the whole calculation had been done with 32-bit hashes. The same truncation + * is applied when a state is serialized for an old server. + * + * In the compat mode, a 32-bit hash h is stored in the 64-bit cell as (h << 32) | h. + * With this encoding both halves of the cell equal h, so the bits used for the position + * in the hash table (the high-order ones) and the bits used for thinning (the low-order ones) + * coincide with what the legacy 32-bit implementation used, and no separate code path + * is needed for lookups and thinning. */ /// The maximum degree of buffer size before the values are discarded @@ -51,7 +69,7 @@ /** The number of least significant bits used for thinning. The remaining high-order bits are used to determine the position in the hash table. * (high-order bits are taken because the younger bits will be constant after dropping some of the values) */ -#define UNIQUES_HASH_BITS_FOR_SKIP (32 - UNIQUES_HASH_MAX_SIZE_DEGREE) +#define UNIQUES_HASH_BITS_FOR_SKIP (64 - UNIQUES_HASH_MAX_SIZE_DEGREE) /// Initial buffer size degree #define UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE 4 @@ -70,17 +88,18 @@ struct UniquesHashSetDefaultHash template -class UniquesHashSet : private HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt32)> +class UniquesHashSet : private HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt64)> { private: using Value = UInt64; - using HashValue = UInt32; - using Allocator = HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt32)>; + using HashValue = UInt64; + using Allocator = HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt64)>; UInt32 m_size; /// Number of elements UInt8 size_degree{}; /// The size of the table as a power of 2 UInt8 skip_degree; /// Skip elements not divisible by 2 ^ skip_degree bool has_zero; /// The hash table contains an element with a hash value of 0. + bool compat_32bit; /// The stored values carry only 32 bits of hash information (see the comment above). HashValue * buf{}; @@ -115,6 +134,13 @@ private: HashValue hash(Value key) const { return static_cast(Hash()(key)); } + /// The representation of a 32-bit hash in the compat mode (see the comment above). + static HashValue replicate32(HashValue x) + { + HashValue low = x & 0xFFFFFFFFULL; + return (low << 32) | low; + } + /// Delete all values whose hashes do not divide by 2 ^ skip_degree void rehash() { @@ -276,6 +302,42 @@ private: } } + template + void readImpl(DB::ReadBuffer & rb) + { + if (m_size <= 1) + { + for (size_t i = 0; i < m_size; ++i) + { + StoredValue x = 0; + DB::readBinaryLittleEndian(x, rb); + insertValueRead(static_cast(x)); + } + } + else + { + auto hs = std::make_unique(m_size); + rb.readStrict(reinterpret_cast(hs.get()), m_size * sizeof(StoredValue)); + + for (size_t i = 0; i < m_size; ++i) + { + DB::transformEndianness(hs[i]); + insertValueRead(static_cast(hs[i])); + } + } + } + + void insertValueRead(HashValue x) + { + if (compat_32bit) + x = replicate32(x); + + if (x == 0) + has_zero = true; + else + reinsertImpl(x); + } + public: using value_type = Value; @@ -283,7 +345,8 @@ public: UniquesHashSet() : // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - base class is an allocator with stack memory, initialized in alloc() m_size(0), skip_degree(0), - has_zero(false) + has_zero(false), + compat_32bit(false) { alloc(UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE); #ifdef UNIQUES_HASH_SET_COUNT_COLLISIONS @@ -292,7 +355,7 @@ public: } UniquesHashSet(const UniquesHashSet & rhs) // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - base class is an allocator with stack memory, initialized in alloc() - : m_size(rhs.m_size), skip_degree(rhs.skip_degree), has_zero(rhs.has_zero) + : m_size(rhs.m_size), skip_degree(rhs.skip_degree), has_zero(rhs.has_zero), compat_32bit(rhs.compat_32bit) { alloc(rhs.size_degree); memcpy(buf, rhs.buf, buf_size() * sizeof(buf[0])); @@ -312,6 +375,7 @@ public: m_size = rhs.m_size; skip_degree = rhs.skip_degree; has_zero = rhs.has_zero; + compat_32bit = rhs.compat_32bit; memcpy(buf, rhs.buf, buf_size() * sizeof(buf[0])); @@ -325,7 +389,10 @@ public: void ALWAYS_INLINE insert(Value x) { - const HashValue hash_value = hash(x); + HashValue hash_value = hash(x); + if (unlikely(compat_32bit)) + hash_value = replicate32(hash_value); + if (!good(hash_value)) return; @@ -342,6 +409,14 @@ public: requires std::is_invocable_r_v void insertMany(const SourceType * data, size_t size) { + /// States downgraded to the compat mode are rare and not worth a separate batch implementation. + if (unlikely(compat_32bit)) + { + for (size_t i = 0; i < size; ++i) + insert(Transform(data[i])); + return; + } + size_t i = 0; while (i < size) { @@ -423,6 +498,13 @@ public: */ res += (intHashCRC32(m_size) & ((1ULL << skip_degree) - 1)); + /** With 64-bit hashes, the systematic error due to hash collisions is negligible: + * it reaches just 0.003% at 10^15 distinct elements, + * which is far below the statistical error of the estimate itself. + */ + if (!compat_32bit) + return res; + /** Correction of a systematic error due to collisions during hashing in UInt32. * `fixed_res(res)` formula * - with how many different elements of fixed_res, @@ -430,12 +512,72 @@ public: * filled buckets with average of res is obtained. */ size_t p32 = 1ULL << 32; - size_t fixed_res = static_cast(round(static_cast(p32) * (log(p32) - log(p32 - res)))); + + /** When the number of distinct elements is much larger than 2^32, almost all buckets are filled + * and `res` may reach (or, due to the pseudo-random remainder, exceed) 2^32, + * where the formula below is not defined (previously this resulted in undefined behavior + * when casting infinity or NaN to an integer). Saturate the estimate instead: + * for larger cardinalities the 32-bit state contains no information anyway. + */ + if (res >= p32) + res = p32 - 1; + + size_t fixed_res = static_cast(round(static_cast(p32) * (log(static_cast(p32)) - log(static_cast(p32 - res))))); return fixed_res; } + /** Convert the state to the compat mode, where elements are identified by only the low + * 32 bits of the hash (as done by the legacy implementation). The stored values are truncated; + * values that collide after truncation are deduplicated exactly as the legacy implementation would do. + * This loses information and cannot be undone. + */ + void downgradeToCompat32() + { + if (compat_32bit) + return; + + compat_32bit = true; + + if (0 == m_size) + return; + + /// Truncated values are placed by different bits of the hash, so the table is rebuilt from scratch. + DB::PODArray values; + values.reserve(m_size); + for (size_t i = 0; i < buf_size(); ++i) + if (buf[i]) + values.push_back(replicate32(buf[i])); + + memset(buf, 0, buf_size() * sizeof(buf[0])); + m_size = has_zero ? 1 : 0; + + for (HashValue x : values) + { + /// A value with zero low 32 bits truncates to the zero hash. + if (x == 0) + { + m_size += !has_zero; + has_zero = true; + } + else + insertImpl(x); + } + } + + bool isCompat32() const { return compat_32bit; } + void merge(const UniquesHashSet & rhs) { + /** If the other state was built (fully or partially) with 32-bit hashes, there is no way + * to recover the missing bits of its hashes. Downgrade this state to the compat mode + * to keep deduplication correct: the result is the same as if the whole calculation + * had been done with 32-bit hashes. + */ + if (rhs.compat_32bit && !compat_32bit) + downgradeToCompat32(); + + const bool need_truncation = compat_32bit && !rhs.compat_32bit; + if (rhs.skip_degree > skip_degree) { skip_degree = rhs.skip_degree; @@ -451,15 +593,35 @@ public: for (size_t i = 0; i < rhs.buf_size(); ++i) { - if (rhs.buf[i] && good(rhs.buf[i])) + if (!rhs.buf[i]) + continue; + + HashValue x = need_truncation ? replicate32(rhs.buf[i]) : rhs.buf[i]; + + if (x == 0) { - insertImpl(rhs.buf[i]); + /// A truncated value with zero low 32 bits becomes the zero hash. + if (!has_zero) + { + has_zero = true; + ++m_size; + shrinkIfNeed(); + } + } + else if (good(x)) + { + insertImpl(x); shrinkIfNeed(); } } } - void write(DB::WriteBuffer & wb) const + /** Two serialization formats exist. + * The legacy format (state version 0): skip_degree, then the number of values, then the values truncated to 32 bits. + * The new format (state version 1): a flags byte (bit 0 - the values carry only 32 bits of hash information), + * then skip_degree, then the number of values, then the values, 4 or 8 bytes each according to the flag. + */ + void write(DB::WriteBuffer & wb, bool use_legacy_format) const { if (m_size > UNIQUES_HASH_MAX_SIZE) throw Poco::Exception("Cannot write UniquesHashSet: too large size_degree."); @@ -467,30 +629,75 @@ public: /// A null `buf` here would indicate upstream state corruption (e.g. a double-destroyed state). chassert(buf); + if (use_legacy_format && !compat_32bit) + { + /// Serialization for an old server. The hashes have to be truncated to 32 bits; + /// the truncated values are exactly what an old server would have computed for the same elements. + UniquesHashSet compat_copy(*this); + compat_copy.downgradeToCompat32(); + compat_copy.write(wb, /*use_legacy_format=*/ true); + return; + } + + if (!use_legacy_format) + { + UInt8 flags = compat_32bit ? 1 : 0; + DB::writeBinaryLittleEndian(flags, wb); + } + DB::writeBinaryLittleEndian(skip_degree, wb); DB::writeVarUInt(m_size, wb); if (has_zero) { - HashValue x = 0; - DB::writeBinaryLittleEndian(x, wb); + if (compat_32bit) + DB::writeBinaryLittleEndian(static_cast(0), wb); + else + DB::writeBinaryLittleEndian(static_cast(0), wb); } for (size_t i = 0; i < buf_size(); ++i) + { if (buf[i]) - DB::writeBinaryLittleEndian(buf[i], wb); + { + /// In the compat mode both halves of the stored value equal the 32-bit hash. + if (compat_32bit) + DB::writeBinaryLittleEndian(static_cast(buf[i]), wb); + else + DB::writeBinaryLittleEndian(buf[i], wb); + } + } } - void read(DB::ReadBuffer & rb) + void read(DB::ReadBuffer & rb, bool use_legacy_format) { has_zero = false; + if (use_legacy_format) + { + compat_32bit = true; + } + else + { + UInt8 flags = 0; + DB::readBinaryLittleEndian(flags, rb); + if (flags & ~1) + throw Poco::Exception("Cannot read UniquesHashSet: unknown flags."); + compat_32bit = flags & 1; + } + DB::readBinaryLittleEndian(skip_degree, rb); DB::readVarUInt(m_size, rb); if (m_size > UNIQUES_HASH_MAX_SIZE) throw Poco::Exception("Cannot read UniquesHashSet: too large size_degree."); + /// The structure never produces skip_degree above 48 (at that point the number of possible + /// values passing the thinning is already below the maximum number of stored elements), + /// and shifting by 64 or more would be undefined behavior. + if (skip_degree >= 64) + throw Poco::Exception("Cannot read UniquesHashSet: too large skip_degree."); + free(); UInt8 new_size_degree = m_size <= 1 @@ -499,45 +706,33 @@ public: alloc(new_size_degree); - if (m_size <= 1) - { - for (size_t i = 0; i < m_size; ++i) - { - HashValue x = 0; - DB::readBinaryLittleEndian(x, rb); - if (x == 0) - has_zero = true; - else - reinsertImpl(x); - } - } + if (compat_32bit) + readImpl(rb); else - { - auto hs = std::make_unique(m_size); - rb.readStrict(reinterpret_cast(hs.get()), m_size * sizeof(HashValue)); - - for (size_t i = 0; i < m_size; ++i) - { - DB::transformEndianness(hs[i]); - if (hs[i] == 0) - has_zero = true; - else - reinsertImpl(hs[i]); - } - } + readImpl(rb); } - static void skip(DB::ReadBuffer & rb) + static void skip(DB::ReadBuffer & rb, bool use_legacy_format) { size_t size = 0; + bool values_32bit = true; + if (!use_legacy_format) + { + UInt8 flags = 0; + DB::readBinaryLittleEndian(flags, rb); + if (flags & ~1) + throw Poco::Exception("Cannot read UniquesHashSet: unknown flags."); + values_32bit = flags & 1; + } + rb.ignore(); DB::readVarUInt(size, rb); if (size > UNIQUES_HASH_MAX_SIZE) throw Poco::Exception("Cannot read UniquesHashSet: too large size_degree."); - rb.ignore(sizeof(HashValue) * size); + rb.ignore((values_32bit ? sizeof(UInt32) : sizeof(UInt64)) * size); } #ifdef UNIQUES_HASH_SET_COUNT_COLLISIONS diff --git a/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp new file mode 100644 index 000000000000..b479cbb9a120 --- /dev/null +++ b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp @@ -0,0 +1,239 @@ +#include +#include + +#include +#include + +#include + +#include +#include + +using namespace DB; + +namespace +{ + +/// The same concrete type that the `uniq` aggregate function uses. +using TestSet = UniquesHashSet>; + +std::string serialize(const TestSet & set, bool use_legacy_format) +{ + WriteBufferFromOwnString out; + set.write(out, use_legacy_format); + return out.str(); +} + +TestSet deserialize(const std::string & data, bool use_legacy_format) +{ + ReadBufferFromString in(data); + TestSet set; + set.read(in, use_legacy_format); + return set; +} + +/// Craft a serialized state with the given skip degree and raw (already hashed) sampled values, +/// to test the estimate at cardinalities that would be too slow to reach by actual insertion. +template +std::string craftState(UInt8 skip_degree, const std::vector & values, bool use_legacy_format) +{ + WriteBufferFromOwnString out; + if (!use_legacy_format) + { + UInt8 flags = sizeof(StoredValue) == sizeof(UInt32) ? 1 : 0; + writeBinaryLittleEndian(flags, out); + } + writeBinaryLittleEndian(skip_degree, out); + writeVarUInt(values.size(), out); + for (StoredValue value : values) + writeBinaryLittleEndian(value, out); + return out.str(); +} + +} + + +TEST(UniquesHashSet, ExactAtSmallCardinality) +{ + TestSet set; + for (UInt64 i = 0; i < 50000; ++i) + set.insert(i); + + /// Below the thinning threshold the set stores all distinct 64-bit hashes, + /// and collisions among 50000 of them are next to impossible. + EXPECT_EQ(set.size(), 50000u); +} + +TEST(UniquesHashSet, EstimateAtModerateCardinality) +{ + TestSet set; + for (UInt64 i = 0; i < 10000000; ++i) + set.insert(i); + + /// The typical error of the estimate is about 0.6%; allow 3%. + EXPECT_NEAR(static_cast(set.size()), 10000000.0, 300000.0); +} + +TEST(UniquesHashSet, MergeDeduplicates) +{ + TestSet a; + TestSet b; + for (UInt64 i = 0; i < 40000; ++i) + a.insert(i); + for (UInt64 i = 20000; i < 60000; ++i) + b.insert(i); + + a.merge(b); + EXPECT_EQ(a.size(), 60000u); +} + +TEST(UniquesHashSet, RoundTripNewFormat) +{ + TestSet set; + for (UInt64 i = 0; i < 300000; ++i) + set.insert(i); + + std::string serialized = serialize(set, /*use_legacy_format=*/ false); + TestSet restored = deserialize(serialized, /*use_legacy_format=*/ false); + + EXPECT_EQ(set.size(), restored.size()); + + /// The state must remain usable after the round trip. + for (UInt64 i = 300000; i < 400000; ++i) + restored.insert(i); + EXPECT_NEAR(static_cast(restored.size()), 400000.0, 12000.0); +} + +TEST(UniquesHashSet, RoundTripLegacyFormat) +{ + TestSet set; + for (UInt64 i = 0; i < 300000; ++i) + set.insert(i); + + std::string legacy = serialize(set, /*use_legacy_format=*/ true); + TestSet restored = deserialize(legacy, /*use_legacy_format=*/ true); + + EXPECT_TRUE(restored.isCompat32()); + + /// The estimates of the downgraded and the original state may differ slightly + /// (only 32 bits of each hash remain), but at this cardinality both are accurate. + EXPECT_NEAR(static_cast(restored.size()), static_cast(set.size()), 10000.0); + + /// A state restored from the legacy format is written in the new format with the compat flag, + /// and survives that round trip as well. + std::string compat = serialize(restored, /*use_legacy_format=*/ false); + TestSet restored2 = deserialize(compat, /*use_legacy_format=*/ false); + EXPECT_TRUE(restored2.isCompat32()); + EXPECT_EQ(restored2.size(), restored.size()); +} + +TEST(UniquesHashSet, MergeWithLegacyStateDeduplicates) +{ + /// Elements 20000..40000 are present on both sides: on the left with full 64-bit hashes, + /// on the right with truncated ones (after a round trip through the legacy format). + /// The merge must not double-count them. + TestSet a; + for (UInt64 i = 0; i < 40000; ++i) + a.insert(i); + + TestSet b; + for (UInt64 i = 20000; i < 60000; ++i) + b.insert(i); + TestSet b32 = deserialize(serialize(b, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); + + a.merge(b32); + EXPECT_TRUE(a.isCompat32()); + + /// The reference: the same union computed with truncated hashes from the start. + TestSet reference; + for (UInt64 i = 0; i < 60000; ++i) + reference.insert(i); + TestSet reference32 = deserialize(serialize(reference, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); + + /// At this cardinality the thinning has not started, so the estimate is exactly the number + /// of distinct 32-bit hashes, and it must match the reference exactly: had the merge failed + /// to deduplicate the common elements, the estimate would be larger by about 20000. + EXPECT_EQ(a.size(), reference32.size()); + + /// Merging in the other direction (a 64-bit state into a legacy state) gives the same result. + TestSet a2; + for (UInt64 i = 0; i < 40000; ++i) + a2.insert(i); + TestSet b32_copy = deserialize(serialize(b, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); + b32_copy.merge(a2); + EXPECT_EQ(b32_copy.size(), reference32.size()); +} + +TEST(UniquesHashSet, InsertIntoLegacyState) +{ + /// Inserting into a state restored from the legacy format must deduplicate + /// against the values that are already there. + TestSet set; + for (UInt64 i = 0; i < 30000; ++i) + set.insert(i); + + TestSet restored = deserialize(serialize(set, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); + for (UInt64 i = 0; i < 60000; ++i) + restored.insert(i); + + EXPECT_NEAR(static_cast(restored.size()), 60000.0, 100.0); +} + +TEST(UniquesHashSet, NoOverflowAtHugeCardinality) +{ + /// Simulate the state left by 10^11 distinct elements: with the thinning degree of 21, + /// about 10^11 / 2^21 sampled hashes remain, each with 21 zero low-order bits. + constexpr UInt64 cardinality = 100000000000ULL; + constexpr UInt8 skip_degree = 21; + constexpr size_t num_samples = cardinality >> skip_degree; + + std::mt19937_64 rng(42); + std::unordered_set samples; + while (samples.size() < num_samples) + samples.insert(rng() << skip_degree); + + TestSet set = deserialize( + craftState(skip_degree, std::vector(samples.begin(), samples.end()), /*use_legacy_format=*/ false), + /*use_legacy_format=*/ false); + + /// Before the switch to 64-bit hashes the estimate overflowed to a value around 1.8 * 10^19 here. + EXPECT_NEAR(static_cast(set.size()), static_cast(cardinality), 0.001 * cardinality); +} + +TEST(UniquesHashSet, LegacyEstimateSaturatesInsteadOfOverflow) +{ + /// A fully saturated legacy state: at the thinning degree of 16 every possible sampled + /// 32-bit hash (all multiples of 2^16) is present. The corrected estimate used to be + /// computed as round(2^32 * log(2^32 / 0)) = inf, and casting it to an integer + /// was undefined behavior that produced values like 18446743978444128518. + std::vector values(65536); + for (size_t i = 0; i < values.size(); ++i) + values[i] = static_cast(i << 16); + + TestSet set = deserialize(craftState(16, values, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); + + /// The saturated estimate is 2^32 * ln(2^32), about 9.5 * 10^10. + size_t estimate = set.size(); + EXPECT_GE(estimate, 90000000000ULL); + EXPECT_LE(estimate, 100000000000ULL); +} + +TEST(UniquesHashSet, RejectsCorruptedStates) +{ + { + /// Too large skip degree. + std::string data = craftState(64, {}, /*use_legacy_format=*/ false); + ReadBufferFromString in(data); + TestSet set; + EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); + } + { + /// Unknown flags. + WriteBufferFromOwnString out; + writeBinaryLittleEndian(static_cast(2), out); + std::string data = out.str(); + ReadBufferFromString in(data); + TestSet set; + EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); + } +} diff --git a/src/Core/ProtocolDefines.h b/src/Core/ProtocolDefines.h index 0dfcf81c4ad3..f9a5b9142084 100644 --- a/src/Core/ProtocolDefines.h +++ b/src/Core/ProtocolDefines.h @@ -218,6 +218,9 @@ static constexpr auto DBMS_MIN_REVISION_WITH_HTTP_HANDLER_IN_CLIENT_INFO = 54490 /// to different degrees does not depend on how the rows were distributed between them. static constexpr auto DBMS_MIN_REVISION_WITH_QUANTILE_DETERMINISTIC_SKIP_DEGREE = 54491; +/// Version 1 of the `uniq` aggregate function state (64-bit hashes instead of 32-bit ones). +static constexpr auto DBMS_MIN_REVISION_WITH_UNIQ_STATE_VERSION_1 = 54492; + /// Version of ClickHouse TCP protocol. /// @@ -226,5 +229,5 @@ static constexpr auto DBMS_MIN_REVISION_WITH_QUANTILE_DETERMINISTIC_SKIP_DEGREE /// NOTE: DBMS_TCP_PROTOCOL_VERSION has nothing common with VERSION_REVISION, /// later is just a number for server version (one number instead of commit SHA) /// for simplicity (sometimes it may be more convenient in some use cases). -static constexpr auto DBMS_TCP_PROTOCOL_VERSION = 54491; +static constexpr auto DBMS_TCP_PROTOCOL_VERSION = 54492; } diff --git a/tests/queries/0_stateless/00973_uniq_non_associativity.reference b/tests/queries/0_stateless/00973_uniq_non_associativity.reference index 9e0c113389ff..9a120bc71911 100644 --- a/tests/queries/0_stateless/00973_uniq_non_associativity.reference +++ b/tests/queries/0_stateless/00973_uniq_non_associativity.reference @@ -1,2 +1,2 @@ -80041 -80041 +80040 +80040 diff --git a/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference b/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference index 57fed5c75ed2..20d6a173fdce 100644 --- a/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference +++ b/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference @@ -1 +1 @@ -['\0\0\0\0\0'] +['\0\0\0\0\0\0\0\0\0\0'] diff --git a/tests/queries/0_stateless/01890_state_of_state.reference b/tests/queries/0_stateless/01890_state_of_state.reference index 0c1777fdbbd0..9e12fb2f5c3c 100644 --- a/tests/queries/0_stateless/01890_state_of_state.reference +++ b/tests/queries/0_stateless/01890_state_of_state.reference @@ -1,6 +1,6 @@ 1 1 -01ACA45BA7DD058B4F68BE1AC382E2E1B9 +019FAED84DEB6883BE8750E4429D9305C0 01C0728DA5A9FCEE86FB6B6A6957296F70 AggregateFunction(uniqExact, AggregateFunction(quantile, UInt64)) UInt64 @@ -8,14 +8,14 @@ AggregateFunction(uniqExact, UInt8) AggregateFunction(uniqExact, AggregateFunction(quantile, UInt8)) 002000000000000001000000000000001C3633363431333632323338343637393330303520302031323334353900 AggregateFunction(any, AggregateFunction(quantile, UInt8)) -AggregateFunction(any, AggregateFunction(uniq, UInt8)) -0001F17752DF -0001FBFD3A96 -0106000100000000 -0106000100000000 -01F17752DF500C1DC06F522F986F0C97C5 +AggregateFunction(any, AggregateFunction(1, uniq, UInt8)) +000001C2FFBEF723D70094 +000001FBFD3A96D95571C4 +010B0000010000000000000000 +010B0000010000000000000000 +01C2FFBEF723D70094F90D214AC9BD1133 1 Row 1: ────── -hex(toString(anyState(x))): 01A31F00E80700000000B89901B4C50F022CBB0E02F4208803683BDE03DC63B4048CC8AA061C36EC06C0401007D05E4A09BC5D560C3085C80FA04C7F17205BC218E0BBAC18609AED19B81F1A1C94E68A1C8468BA1FDCC9EF203C38B925E0E17F2828DD5928243B7929806B852804AC9E2B4474A82BA0FD9C2CC4C2FC2D68234530A86A3431D42B6A3234958B33047214353830553514D0C5364CE22D373826C137503E3438285B4238E4DD5639B4E24439A054443AD030433B0819723B847AC33C3C45313DD8D0743F3895563F0CB5694074077C4188BBF64114190A42B893DF41789724415C13FA43E81E074408C1F9447CCC57456CF0D6452440F045AC85B244A4200E4A00258E4AB837FA4CC451834C44DBD64DE056DE4D64187E4FA4C3FF50505EE65150E766526C765C53B07CD853CC9AF855B407E0562455A25700CC69585428D559EC68235AFC44A45910A6705B785B355E101006608882F661183A676230B24962CCC1F462B056D46468ADE164940E7E6704DDD168E461AA6A28CC626C9CF2476C04DF846CA8349F73448F0675609DDF7584B94A77A400F17898FC87793C0C6B7A50174A7CF87B2E7D64223C7E7C0DCC7E0417F87FF8A42E8088632981440D4381E000FA83B47C478450EA3C84805577841051A086BCA0C08780D8F58720C6638ADCF14F8B54A25A8E04B5D38EACEFE08F00873A91F890D2914CC62792BC9F2192944A2993804B9192F4E92996D8DC7A9988DB9D992415D69A04AB809A303B899B8C40A69AB4868D9C48DA059B5864E59B08586B9EA8CE189FC49138A0380CDEA1987D30A2D04CBFA2B4365CA34468DAA3E0DE91A5C435B0A844C4E5A8C49EDAA9C4C274AAC4D0DEAA68F662AB300414AC843557AC0C9250AD10397EADC0502BAFE820CBAF8086E6AF7C88F0B15CE684B2F8F32CB494773EB53C089FB97011F5BA7C508CBABC9643BD3C4AC9BD54BCC4BE70BE83BF8CBB3AC0088499C06C35E6C0B87D79C05CA902C2E42CCBC234ECCCC0400344C51826B4C50C9914C7084E66C9E01982CA503659CB982DE2CE1858B0D0249B21D10470E9D2A40364D3D472A8D424DBD0D6C44AEBD65CABEED7CC9B36D8789951D8F8C5EED6E81DDBD9B405D3D9280986DCB852CBDD0091B0DDA020E6DE181976DFDC86CCDFB0C7E1DFF0B99AE0482700E088B5D8DD0C3F91E258275AE3289682E4A8B1E4E40CBE86E6789EEDE7EC8F01E90C1BF0EA342925EB2C2063EC784FFDEC94BE9DEE847518F0A8F1D4F26430C9F2ECA2ECF31CF80FF3F875B4F5A088C9F69CD798F7503F65F8F45ED7F8B082B4FA148FCEFB700534FCA8CCE8FC1C5242FC584C3AFDE83EB4FCECF6C6FDB01BEFFD245BD8FF70569700EDC05A02F1B7060691E7830665D4790881F88E08A17A7B0925E5010A91939B0D1D209B0E6129890FDDA7C7106951CF1191551413A106AA15F1D6761661F27E1B59D34B1B25D9231BE144B51C89B2DF1DC531921DD1EAB31DF506671F8177A91FE59ED91FA199C520C18D312115F1881FE952871FB9BD262145F32A2385E03923B1761024BD288B241D097125E965D02631DD0C27299F742741DF6F28852B112831312828AD8DD629B5A93B2B2D9DCB2CE90AD12CA9F73B2E71BB5A2FF15EEB2F291A003075D5512F75E12B319D9B6E311D7AF332DD4F2A35AD642F36A15DE836753AEB362192C03631E4BE37D9156939ED5CD53A8945073B5DAE9D3A293D323C0D8F703B6194F83D6949A73DC98FB73DDD237140F19C3F4059CB0241210F30417DFBB941E9543943E5FB6044A119A545B10DD348257FD4485974B64975622C4AA5389F4995BD234B09629D4B3975554CD57EE94A8D0B265095964B51518E5054DDFD05548DA0BD5551A6FE57E95B6558699EF257FD4C9C5FB9EA4261B5A8F661C9DBD7614D31EB61ED6B7061A907DE61397B5D6581B69265D12B496741C5D0690941256A0D023C6BA598326BF55C227129624B73C1738B735509317449907A743DA0FE761D027177017B71795174257985877F7919DDAC79D5E4127C9DCD4F7D3D4F917D2937E77E0DEA797F5D74057F6553518009975F8169C0BD81B5E263820570D58171298C832DA98484CD0CD9852DDFA88671E3508739795288313E0A89CD8FFE8905C66D8A6D4C688B9944F98B91A3C48C7999528EC90C149365D8D0936178A194619C3F95F1BE8D9605C1A49709510B98856CE49875A8B0987D947699F97E299C3168AA9C55F4189D59D3529EC55FFF9EF158A39EC15F77A17154D7A175C629A27D5C1FA319ECF1A5C1EDF3A6DDE550A9C95B3CAAC5D865AA817A03AC71058AAC5D9396AC658283ADE9B8CBAC251DFFAE25A775B10DBB9AB1018E62B3B5C12DB3D5EAB6B4E57196B68D4791B8E9A596BB016430BC1DFBB6BB1933ECBEB94A13C0E98632C0557335C111D4E6C1511061C1E5A9BFC22DBD42C3AD3DD7C325DEFEC30DEBA5C17541CFC51DE252C651461AC7C10DF4C865700BC9BDEEB4C9B13562CAC56ABACA59D44AC91D9348CB1D26A3CC65014CCF356B1AD135BD88D1B52C2BD2E57A95D3B5A621D4419DA4D449C472D5A1EBDCD521E350D85D41C6D8717C6DD8C51394D9D1EF36DBB53388DB1D7825DBFD73E5DC7D6916DF2D82EFDFD9CA63DF6DEC5AE18188B2E1191152E32DAE7EE4818D18E5C9B15DE511DE2FE6BDB2B6E649D732E5E1F7F5EA6DECE0EB8D92DEEBDDBBAFECC55BA3EE6DB981EE65CEE0EEC99223F1513191F19DF97CF2E5ED11F375728EF30512EAF4DD0D01F55D84E9F5F5C796F5DD4D8FF6959879F785CED8F8091563FB71E604FC092A75FCA1FD2CFDD1326FFE7D23E5FEE9CE2EFF957A2E01B6A05C04662FA504DE9EA604725AA706C6A43C0ACA0A580BF233800B96676B0D725C3D0DAE5A100E5E86400F76A6791002B26A11FAEBE71146FB01139AD16E141E4C21189E5D2418EAF263190AEC881E42FCDF1EFA4AAB1F861A4D2186552821F6E07C227E4CD4221E970423EAEDD623D22D062412A82D22F68D3E251A6862290EE5C02A6E0D542B62BF772B46509E2A02EF182E520A5131E6D92431E6D670320A6F9131BED3A8310E07BF33823F0C321A8C643206431335729AA335C6F9233682A697364ABE483852270839DE5E3D39CA3F323AE67BC13BDAFBE33BBE9C263FDA46E9405E92E8418ACC59428E8F8742CAD63744A2FBF4447A654F45B2F9CA45E24470472EDA0C489EED20487ACF10494AEAFC496E1B4B4A3ADC474AEA873B4B6AA548497A1C774C0ABF824C9209CF4A569FBC4D9ABA194E5294DB4EBABFF44AFEE1F64EDE59154FB61999502232BA503692FF528A1BAD53EA6927550AE7515676F1BF5692F552572ED2DB58EA8D7B59DEB29F5A120C535B924A065C1EF7C45D0A2FE95D9A49F05F9AE00360AA9B9260DA75B7626601FB630EA901647A76DF64EA4BA164B6073F66EE26D46C8ABAD06DEAD15E70FE19AA70A2B39C73D2782B75329492759616D077EEB6A378AE837D791E5293796E5CCB7956B19D7B369B8A7B1AD9367CFAD31E7DA2D6057DA266747E0EDB9F7E9ED6027F425F5A80CA960582C28D07821A648B857267B086769BF088F20BBD8A4660938C1E87DF8DC2125A8E66FAEC8D42B019918A5149927ECC55936A29F095BE6E9296AE6B4797223E229976BD7999AE27959A56407D9B168AB49CB6174CA02AE3FBA05E1EF7A0369493A1EAD033A24E880CA49EFCD5A43AB05AA6722EA6A62AB849A7625302A6FAEC5EA8362E4BA7862A47A696E4FAA9C60633AD9ABC74ADC60EF4AF5E6B0EB04644D5B03AC580B37AFB3AB7BE295EB9A2A7A1BABABE6CBC8E6ED6BCAAF61CBCD2B351BCB6E942BEAE8CF4BEEE1CFFBE92F5BCBF6EFE02C052CC4AC20650ECC22E2B85C4FEE336C5F64C33C606070DC926627BCD9698CCCD4A08F8CF32A9A8D14A97AAD1B6DBEBD132A509D46A13BED6BE90ECD8D67B5ED9CEC673DB4613A8DB02A2E2DCA6512DDEBEF998DE06B246DFF62349DFE2337DE166AAACE1CE734EE20E37C5E34EC596E682E616E72A1554E70AAF87E8929615E9B27AF9E922566CE9A6CD7EE97A8EDDEBBA3F69ECBE5F44ED62EB97ED5E7D9FEE1E7AA5EE3A17D1EFFAE261F152C541F3E64991F392B1CCF48E847CF5827CA7F6066708F84ACCBCFB825BE3FC0A3B03FD36D195FC621E35FE764E97FE46271AFF76F52C023341DC02BB184303CF3D2B04AF0908049FDD6A05AB553805938359065B99FB060BE207059F76D7075B8103087331F208F71F2E091B7E750A87F0BA0A9FBE510DE38FC70E57C48B0E8F5A6F10EF03E5109FE7211257163213F3F0641413EFA416D303D817E357D517D3D19B172B038319EBAC4A1A3F61CA1A170B6F1BE3B92E1B7F3E7722F7FA7C24E34B6724C3DC6026F71B462697707B27EF53F627F33BF027CF72642817B1A127434592294BF3282BFBED2C2BBBBB0E2D6F05162D1FCA6B2E0B4D762EDB5CA52FDF5061317BAA9E31FB25A131DFF13E32CB2A2C3347EF2C34CB69E536D39156377BE9313A330EF63A2703663D93063E3E4BD7093F0FBCEC45C7BA7246A786944603092D47B31D7047F3A348491B33054A87F2184A474BB94A3FA0BE4D4B9B8E4E2B888E4F5B1AB150BBE81951E7FDE7519F1E48523FC6D152F3B678548BE7AF55DF5EBA5623F815572FC080587B4B5D5C5BDAD55C53A42C5EB3BDC85E7BDC535E43954262FF3CF062435A7664CFF475643F34146663D7AE66DB31F166AF91E567877A63683360BD6743008E69437A596B3348766CEB8C326CA71BB06E4740476F7F99BE70B3DD5771A38F2271D394D973ABD05D746F4BFA779FD66578074EC0770776BF78B77D3E790F58667AC76ECE7A13A0AC7A4FBA5F7B1773457CC773297E7B37D27FB7CBBE83CFFF2584ABB376841B4565856FE245874760F88AEB55758BD7C5A58CA330658D8B9DE58D13CADD8E33A74D8F2F9F3B91BB735292BB6E7C9287736D932B393294CB3F4195DFBECE95ABD5749533C73796D7159699A317469A27247E9CEFE69A9C8BEB1D9D5BC4819F9FEB849FC7AF5AA08B5C20A19B7FC1A0FB0F59A27B9F899F9B7A11A36B87C6A3930A22A2273E3BA51F790CA5A36361A6BF0745A5EFD72EA8BBFEF3A84FB1BDA83FCD56A86B5873AA17A016AA3F4F14ABCFE6D7ABEF9947AC6BC91EAC8B7240AACF4224AE3FC03DB2FF9878B3978E29B34FB213B4A39E76B7A7A793B8BBE211B93B04E3BBAF682CBD5BD869BECF2B97BE8FD1A7BE9794C7BEDBB659C4DF70B7C50B9609C6CF4489C6D3373EC9031A31CA13BD5FCCFB3656CDCF656DCD13E44ECEA7184FCE3B51B1CD5341AACE3FE86CD01B8E5FD2DFB130D35F2A4FD353B84AD4B7E606D31B6C7DD57FA7E9D543500BD59F0456D50324BDD7BBEAF5D843804DD93F05D2D84351C3DA7F3671DBFF3B18DBFF784EDB7BAF7DDE17E20BDF770268E08FF3DAE0137604E07BE9D6E12F27DFE3B3F3F9E44BD3CAE657E41CE78B99BEE72F2BFCE82F9D65E9631B02EABB3717EB8FA0B5EBFB7D65EC37C0A6EC5F3520EDEF5B65EC17CC7AEC1FF185EFFB59D5F02336FBF31F7A42F48FD5F1F4F78DD2F4279646F6DF7BB2F61B518FF9F31DEDFA03B517FBBF4108FB73C18AFB1BE35CFD0FA623FDB31E93FD133EFDFEC7272FFFDF -hex(toString(any(x))): 00E80700000000B89901B4C50F022CBB0E02F4208803683BDE03DC63B4048CC8AA061C36EC06C0401007D05E4A09BC5D560C3085C80FA04C7F17205BC218E0BBAC18609AED19B81F1A1C94E68A1C8468BA1FDCC9EF203C38B925E0E17F2828DD5928243B7929806B852804AC9E2B4474A82BA0FD9C2CC4C2FC2D68234530A86A3431D42B6A3234958B33047214353830553514D0C5364CE22D373826C137503E3438285B4238E4DD5639B4E24439A054443AD030433B0819723B847AC33C3C45313DD8D0743F3895563F0CB5694074077C4188BBF64114190A42B893DF41789724415C13FA43E81E074408C1F9447CCC57456CF0D6452440F045AC85B244A4200E4A00258E4AB837FA4CC451834C44DBD64DE056DE4D64187E4FA4C3FF50505EE65150E766526C765C53B07CD853CC9AF855B407E0562455A25700CC69585428D559EC68235AFC44A45910A6705B785B355E101006608882F661183A676230B24962CCC1F462B056D46468ADE164940E7E6704DDD168E461AA6A28CC626C9CF2476C04DF846CA8349F73448F0675609DDF7584B94A77A400F17898FC87793C0C6B7A50174A7CF87B2E7D64223C7E7C0DCC7E0417F87FF8A42E8088632981440D4381E000FA83B47C478450EA3C84805577841051A086BCA0C08780D8F58720C6638ADCF14F8B54A25A8E04B5D38EACEFE08F00873A91F890D2914CC62792BC9F2192944A2993804B9192F4E92996D8DC7A9988DB9D992415D69A04AB809A303B899B8C40A69AB4868D9C48DA059B5864E59B08586B9EA8CE189FC49138A0380CDEA1987D30A2D04CBFA2B4365CA34468DAA3E0DE91A5C435B0A844C4E5A8C49EDAA9C4C274AAC4D0DEAA68F662AB300414AC843557AC0C9250AD10397EADC0502BAFE820CBAF8086E6AF7C88F0B15CE684B2F8F32CB494773EB53C089FB97011F5BA7C508CBABC9643BD3C4AC9BD54BCC4BE70BE83BF8CBB3AC0088499C06C35E6C0B87D79C05CA902C2E42CCBC234ECCCC0400344C51826B4C50C9914C7084E66C9E01982CA503659CB982DE2CE1858B0D0249B21D10470E9D2A40364D3D472A8D424DBD0D6C44AEBD65CABEED7CC9B36D8789951D8F8C5EED6E81DDBD9B405D3D9280986DCB852CBDD0091B0DDA020E6DE181976DFDC86CCDFB0C7E1DFF0B99AE0482700E088B5D8DD0C3F91E258275AE3289682E4A8B1E4E40CBE86E6789EEDE7EC8F01E90C1BF0EA342925EB2C2063EC784FFDEC94BE9DEE847518F0A8F1D4F26430C9F2ECA2ECF31CF80FF3F875B4F5A088C9F69CD798F7503F65F8F45ED7F8B082B4FA148FCEFB700534FCA8CCE8FC1C5242FC584C3AFDE83EB4FCECF6C6FDB01BEFFD245BD8FF70569700EDC05A02F1B7060691E7830665D4790881F88E08A17A7B0925E5010A91939B0D1D209B0E6129890FDDA7C7106951CF1191551413A106AA15F1D6761661F27E1B59D34B1B25D9231BE144B51C89B2DF1DC531921DD1EAB31DF506671F8177A91FE59ED91FA199C520C18D312115F1881FE952871FB9BD262145F32A2385E03923B1761024BD288B241D097125E965D02631DD0C27299F742741DF6F28852B112831312828AD8DD629B5A93B2B2D9DCB2CE90AD12CA9F73B2E71BB5A2FF15EEB2F291A003075D5512F75E12B319D9B6E311D7AF332DD4F2A35AD642F36A15DE836753AEB362192C03631E4BE37D9156939ED5CD53A8945073B5DAE9D3A293D323C0D8F703B6194F83D6949A73DC98FB73DDD237140F19C3F4059CB0241210F30417DFBB941E9543943E5FB6044A119A545B10DD348257FD4485974B64975622C4AA5389F4995BD234B09629D4B3975554CD57EE94A8D0B265095964B51518E5054DDFD05548DA0BD5551A6FE57E95B6558699EF257FD4C9C5FB9EA4261B5A8F661C9DBD7614D31EB61ED6B7061A907DE61397B5D6581B69265D12B496741C5D0690941256A0D023C6BA598326BF55C227129624B73C1738B735509317449907A743DA0FE761D027177017B71795174257985877F7919DDAC79D5E4127C9DCD4F7D3D4F917D2937E77E0DEA797F5D74057F6553518009975F8169C0BD81B5E263820570D58171298C832DA98484CD0CD9852DDFA88671E3508739795288313E0A89CD8FFE8905C66D8A6D4C688B9944F98B91A3C48C7999528EC90C149365D8D0936178A194619C3F95F1BE8D9605C1A49709510B98856CE49875A8B0987D947699F97E299C3168AA9C55F4189D59D3529EC55FFF9EF158A39EC15F77A17154D7A175C629A27D5C1FA319ECF1A5C1EDF3A6DDE550A9C95B3CAAC5D865AA817A03AC71058AAC5D9396AC658283ADE9B8CBAC251DFFAE25A775B10DBB9AB1018E62B3B5C12DB3D5EAB6B4E57196B68D4791B8E9A596BB016430BC1DFBB6BB1933ECBEB94A13C0E98632C0557335C111D4E6C1511061C1E5A9BFC22DBD42C3AD3DD7C325DEFEC30DEBA5C17541CFC51DE252C651461AC7C10DF4C865700BC9BDEEB4C9B13562CAC56ABACA59D44AC91D9348CB1D26A3CC65014CCF356B1AD135BD88D1B52C2BD2E57A95D3B5A621D4419DA4D449C472D5A1EBDCD521E350D85D41C6D8717C6DD8C51394D9D1EF36DBB53388DB1D7825DBFD73E5DC7D6916DF2D82EFDFD9CA63DF6DEC5AE18188B2E1191152E32DAE7EE4818D18E5C9B15DE511DE2FE6BDB2B6E649D732E5E1F7F5EA6DECE0EB8D92DEEBDDBBAFECC55BA3EE6DB981EE65CEE0EEC99223F1513191F19DF97CF2E5ED11F375728EF30512EAF4DD0D01F55D84E9F5F5C796F5DD4D8FF6959879F785CED8F8091563FB71E604FC092A75FCA1FD2CFDD1326FFE7D23E5FEE9CE2EFF957A2E01B6A05C04662FA504DE9EA604725AA706C6A43C0ACA0A580BF233800B96676B0D725C3D0DAE5A100E5E86400F76A6791002B26A11FAEBE71146FB01139AD16E141E4C21189E5D2418EAF263190AEC881E42FCDF1EFA4AAB1F861A4D2186552821F6E07C227E4CD4221E970423EAEDD623D22D062412A82D22F68D3E251A6862290EE5C02A6E0D542B62BF772B46509E2A02EF182E520A5131E6D92431E6D670320A6F9131BED3A8310E07BF33823F0C321A8C643206431335729AA335C6F9233682A697364ABE483852270839DE5E3D39CA3F323AE67BC13BDAFBE33BBE9C263FDA46E9405E92E8418ACC59428E8F8742CAD63744A2FBF4447A654F45B2F9CA45E24470472EDA0C489EED20487ACF10494AEAFC496E1B4B4A3ADC474AEA873B4B6AA548497A1C774C0ABF824C9209CF4A569FBC4D9ABA194E5294DB4EBABFF44AFEE1F64EDE59154FB61999502232BA503692FF528A1BAD53EA6927550AE7515676F1BF5692F552572ED2DB58EA8D7B59DEB29F5A120C535B924A065C1EF7C45D0A2FE95D9A49F05F9AE00360AA9B9260DA75B7626601FB630EA901647A76DF64EA4BA164B6073F66EE26D46C8ABAD06DEAD15E70FE19AA70A2B39C73D2782B75329492759616D077EEB6A378AE837D791E5293796E5CCB7956B19D7B369B8A7B1AD9367CFAD31E7DA2D6057DA266747E0EDB9F7E9ED6027F425F5A80CA960582C28D07821A648B857267B086769BF088F20BBD8A4660938C1E87DF8DC2125A8E66FAEC8D42B019918A5149927ECC55936A29F095BE6E9296AE6B4797223E229976BD7999AE27959A56407D9B168AB49CB6174CA02AE3FBA05E1EF7A0369493A1EAD033A24E880CA49EFCD5A43AB05AA6722EA6A62AB849A7625302A6FAEC5EA8362E4BA7862A47A696E4FAA9C60633AD9ABC74ADC60EF4AF5E6B0EB04644D5B03AC580B37AFB3AB7BE295EB9A2A7A1BABABE6CBC8E6ED6BCAAF61CBCD2B351BCB6E942BEAE8CF4BEEE1CFFBE92F5BCBF6EFE02C052CC4AC20650ECC22E2B85C4FEE336C5F64C33C606070DC926627BCD9698CCCD4A08F8CF32A9A8D14A97AAD1B6DBEBD132A509D46A13BED6BE90ECD8D67B5ED9CEC673DB4613A8DB02A2E2DCA6512DDEBEF998DE06B246DFF62349DFE2337DE166AAACE1CE734EE20E37C5E34EC596E682E616E72A1554E70AAF87E8929615E9B27AF9E922566CE9A6CD7EE97A8EDDEBBA3F69ECBE5F44ED62EB97ED5E7D9FEE1E7AA5EE3A17D1EFFAE261F152C541F3E64991F392B1CCF48E847CF5827CA7F6066708F84ACCBCFB825BE3FC0A3B03FD36D195FC621E35FE764E97FE46271AFF76F52C023341DC02BB184303CF3D2B04AF0908049FDD6A05AB553805938359065B99FB060BE207059F76D7075B8103087331F208F71F2E091B7E750A87F0BA0A9FBE510DE38FC70E57C48B0E8F5A6F10EF03E5109FE7211257163213F3F0641413EFA416D303D817E357D517D3D19B172B038319EBAC4A1A3F61CA1A170B6F1BE3B92E1B7F3E7722F7FA7C24E34B6724C3DC6026F71B462697707B27EF53F627F33BF027CF72642817B1A127434592294BF3282BFBED2C2BBBBB0E2D6F05162D1FCA6B2E0B4D762EDB5CA52FDF5061317BAA9E31FB25A131DFF13E32CB2A2C3347EF2C34CB69E536D39156377BE9313A330EF63A2703663D93063E3E4BD7093F0FBCEC45C7BA7246A786944603092D47B31D7047F3A348491B33054A87F2184A474BB94A3FA0BE4D4B9B8E4E2B888E4F5B1AB150BBE81951E7FDE7519F1E48523FC6D152F3B678548BE7AF55DF5EBA5623F815572FC080587B4B5D5C5BDAD55C53A42C5EB3BDC85E7BDC535E43954262FF3CF062435A7664CFF475643F34146663D7AE66DB31F166AF91E567877A63683360BD6743008E69437A596B3348766CEB8C326CA71BB06E4740476F7F99BE70B3DD5771A38F2271D394D973ABD05D746F4BFA779FD66578074EC0770776BF78B77D3E790F58667AC76ECE7A13A0AC7A4FBA5F7B1773457CC773297E7B37D27FB7CBBE83CFFF2584ABB376841B4565856FE245874760F88AEB55758BD7C5A58CA330658D8B9DE58D13CADD8E33A74D8F2F9F3B91BB735292BB6E7C9287736D932B393294CB3F4195DFBECE95ABD5749533C73796D7159699A317469A27247E9CEFE69A9C8BEB1D9D5BC4819F9FEB849FC7AF5AA08B5C20A19B7FC1A0FB0F59A27B9F899F9B7A11A36B87C6A3930A22A2273E3BA51F790CA5A36361A6BF0745A5EFD72EA8BBFEF3A84FB1BDA83FCD56A86B5873AA17A016AA3F4F14ABCFE6D7ABEF9947AC6BC91EAC8B7240AACF4224AE3FC03DB2FF9878B3978E29B34FB213B4A39E76B7A7A793B8BBE211B93B04E3BBAF682CBD5BD869BECF2B97BE8FD1A7BE9794C7BEDBB659C4DF70B7C50B9609C6CF4489C6D3373EC9031A31CA13BD5FCCFB3656CDCF656DCD13E44ECEA7184FCE3B51B1CD5341AACE3FE86CD01B8E5FD2DFB130D35F2A4FD353B84AD4B7E606D31B6C7DD57FA7E9D543500BD59F0456D50324BDD7BBEAF5D843804DD93F05D2D84351C3DA7F3671DBFF3B18DBFF784EDB7BAF7DDE17E20BDF770268E08FF3DAE0137604E07BE9D6E12F27DFE3B3F3F9E44BD3CAE657E41CE78B99BEE72F2BFCE82F9D65E9631B02EABB3717EB8FA0B5EBFB7D65EC37C0A6EC5F3520EDEF5B65EC17CC7AEC1FF185EFFB59D5F02336FBF31F7A42F48FD5F1F4F78DD2F4279646F6DF7BB2F61B518FF9F31DEDFA03B517FBBF4108FB73C18AFB1BE35CFD0FA623FDB31E93FD133EFDFEC7272FFFDF +hex(toString(anyState(x))): 01C43E0000E80700000000000000003388DB1D6772005049A73DC93E5F02C81E0744088A200314F27E1B5971930364D869BECF448F031CDDD168E4CB29077411F5BA7C00F407804BB94A3F3CD3072C939B0D1D3EB909881BEFFD2451F50B484D762EDB911F0C04E5010A912E450DE41B4B4A3AB035109893DF4178AA3C1054F3282BFBF14212A07B5D6581ACC013007D9FEE1E0F8915CCA0B5EBFB1F7718204F2A35AD8EF3181C07DE613913491944F14F8B54993D1A2072A8D4244BF31BD0C03DB2FF5D7E1CE030658D8BD9B21D144791B8E9C9F01F5075B76266DD8D1F3CC48B0E8F286021F4D1A7BE9744CB217C68DAA3E018A22230E7515676E3852440500BD59FFE5E25283B03FD36BB272950CA6B2E0B4EA729B0FEF3A84F9F362A7894927596A3F32A646361A6BFFEC72934A621D4411DC729A4C5EED6E842B62B087E299C3163C22CCC59154FB6E6E7297C82EFDFD9DDE52D847AA5EE3AFE5A2E08B15DE5110D9C2ED013FA43E8B22A2FA0295EB9A235C32E7C5D560C305704324CA3C48C79FD5C359472143538356F3608954262FF95CA36203AEB36210B023730E550A9C996E637A08CF4BEEED85438DC847CF58217CC383CD0C5364C006339C05EE651503651364C81030873356C3BBC61CA1A17B3653B2856DE4D6457073CA89B21D104A35D3DA0373EC903EADD3DBC6F9131BE6D393EAC15D69A04C5A43E20270839DE6FE23F9C8F2271D393E13FA09AF855B41707417CCE2EFF95ED3842F0D8F5872027CA433C20CBAF80A1F0434825A131DFE53D46708F06756079724AFC7FC1A0FB06A74A2451CF11916C144B20A016AA3FFDBD4A505302A6FA1ABF4AD48D312115BAA04D4469E536D347494E5CD31E7DA2BCF14EFC85B244A4CDBE50643414666367E452F4E211B93B40AD52E85C227129871E54B8FDE7519F3E6D54F0765C53B0608355347B7179511B9F553C2CCBC234FCBC56B4F52C0233138E56F42925EB2C47E755C837FA4CC4402A5808C1F462B0193356B405D2D843BA8F57445B4238E41C9E59504F917D29171A5AD0E17F2828CBBD5D40D732E5E11EB75EAC2FA504DE0C435F6077A91FE550DF5F68184FCE3B2D445FD058667AC7C7BD60340DD3482544726164837D791E1E0C60E074B649753CED62ACA7A1BABA2ABC620CF32A2385376564F00534FCA8CEC16480BBAC1860AE0864985CCB79565DA765F8F998DE0636336628A548497A052E660C4C33C606183567AC30433B08A0D368C0F7C45D0A8EC96980CD7EE97A6FD96C04A2E2DCA675776DBC2B85C4FE2B956D408D07821AE074713061AA6A287690717CE24439A0BB4B7270970423EA85D872BC014CCF3597C3722CF61CBCD263A373B8543943E50CB075E4642F36A1F603764C41256A0D114078ACDC6026F74C2D790C6E7C9287D4BB7B785B65EC17E3D47D783F69ECBEBB037E589156377B1FBF7EE488F0B15CCA297E10D033A24E51227FB417D1EFFAC80A808852871FB946E180E85ED7F8B0936281681394D9D15CC582246ED6BCAAE94C839048766CEBB408855C41CFC51D7543875C4991F3925E90879899BEE72F691788D85A7664CF924D8B28DFA886710EBB8B00E3508739E1B88C903F91E258207B8DE44BA164B649A08DC8BD5FCCFB2FF18E84223C7E7C7C018F8C3D2B04AFBBA38E145242FC586C379024EAF5D843EBD590CCA775B10D7534904474057F658254924C33800B96E78F939CEB97ED5EC53F94D457D517D3C50B961C51C3DA7F6D0C96609C263FDAFE539794A596BB013EA697345F44ED62F2A397945C20A19B5502998486CCDFB0307D9FC465D026315105A01CB376841B8655A0643659CB98AB05A2B8F185EFFB6819A3DC349F7344D8D4A3B8B29F5A129735A5D81A4D2186F4DDA760BB3AC0088D09A8649DCB2CE99906A944EAB6B4E525B0A9E07AF332DD895DAB5C46E9405E7046ACDCA43C0ACAC223AD0488B2E1199E1FAEA8AF87E8923321B0B0073F66EE89DDB0EC63B4048C178AB10C2349DFE2708AB168D195FC62B2E4B154D7AE66DBB96EB31C64E59B08F834B31CCADD8E335F43B46073457CC7B1B3B4E8FC87793C7295B68CAA9E31FB7B6CB7EC773EB53CCB00B80094F83D69444FB924BD42C3ADFD81B9F00986DCB86052B9C0F13E32CBD16BBAE0C3FF5050967BBB8006671F81757ABC343B18DBFFC224BC0054D7A1751573BDA08E5054DD359BBDEC0A5131E6EBA7BDF85E4A09BC1986BEC0DBEBD1326DDFBDDC5BE3FC0AA6CFBFF4624B73C181A6C034B1CCF48E8F7BC1680745A5EFD895C31803D817E32941C51C008E6943D307C6DCFCD5A43A6EC2C6BC3F0C321ADD12C67C0B6F1BE3C0BFC6ACB659C4DFD49CC70C4D8FF695171AC954E3FBA05EB67DCA1C4AEBD65CDE68CAACD676166177CECBF0C0A6EC5FA229CCF8F1D4F2641EC2CC004B9192F40A5CCC18734EE20E637FCC6019723B849E55CCF04AC9BD54A4F8CBE8EC5EA8364AFFCFE82EA6A62AA282D0606916DF2DAE03D1309F742741F632D2C805162D1F9423D4800EF63A27CB48D5F897AAD1B6E0E0D58C9646F6DF92BED6E0D72EA8BB7055D7880908049FEC0DD87870E9D2A45C7AD8C4EA3C8480BCBADD9851A086BC9A49DF3C5F5A80CA6985DF3C5BC218E0415BE0904C21189E394EE0687CD853CC7188E0D02A4FD353C64ADF64566CE9A67A2CE2D4B92E1B7F5F8CE220A98484CDEEEBE2F0BE86E6786FFFE3B4B26A11FAAE15E47894C7BEDBE846E614397EADC06DBEE68000FA83B45307E8F0790CA5A3702AE930DAD55C53CD14E9806B0EB046582EEB74312828AD516CEB6C6927550AD6E6ECBCA42E80883FFCED48E4FAA9C64577F0601D7047F35790F1F887DF8DC2C65CF2980B26509561A3F264CD4F7D3D48A1F2F03D323C0D6FB1F300D16E141EB57DF45085C80FA0DDA2F4CC35B0A844382BF5880D542B62F3FEF59498CCCD4A153DF60C4224AE3F3001F50819AA70A24C9EF7F41563FB718751F86007BF338286FEF83840476F7F3CE4F97844B51C899015FB94CD56A86B468DFEB80F30417DAC8FFF28DD6A05ABAF48007DAC4A1A3F33E7FFF40C1493658345010166747E0EEAA101199EDAA9C412240031551413A13035030533054A8720080631DDAC79D5D39906216E9296AE2533072173297E7BB9B60785CC59428E607908815A100E5E4BD2094590ECD8D669110AB9ED2C2BBB4CDD0CA578A19461CFBB0D29736D932B87890E554592294BF4BA0E8905D3D9286C0612B97A596B33875012BDFB3AB7BE738312510D01F55DE3E71321CC6958542F4A14A5C12DB3D5F6E715DDBE510DE31C761701E4127C9D77AB17D549F05F9A808C179D16D077EE77E2187D4E66C9E0F5F71899F1BF5692ED331AB1BC74ADC6B0C21A7D728EF30526631CD1DD0C272904F81D61D5F1F4F71F9A20916329814441BF20F1FD2CFDD188B62315DC535E434F60244D393294CBBAF726A53E7722F7714927CD92DEEBDD72A1277DD92431E62EA728458E62B3B5FD252DF913A8DB0268012E755149927EA1D12E1D8DD629B5ED122FC5F97CF2E5C7F12FCDAE7EE48166D230397AC33C3C160433198FFE8905FD0A34B9629D4B39CAFC34119E76B7A7E4B537EDDF846CA8D4EB38BD174A7CF818773951337DE1668F913AD5509E2A024E173BF517F87FF868983BD5C4E5A8C48FFD3B85DB9F7E9E14BE3C258694460322683DED90D2914C7CE53D09E86CD01B402D3EB97BC13BDA84C13EF56B479722F7C63C896708F84A2D52403D1E93FD13B5C740850C6B7A50FE7F414591B0DDA01A9D4015EBDCD521631B424DAB809A300ADB42A976D7075B06DD4121569700EDAAB94469E20BDF77D13246B92E4BA7869F6F47216ABACA596D4649CDA42C5EB3D17D49717D79C05CA8CE4A314CBFA2B4B22C4BC9B5D8DD0C71FE4AA1EDF3A6DD70124D2D9FBC4D9ACDB94D1976DF64EA147C4E894C3AFDE8508A4D911152E32D6F3151392B496741A572525D3671DBFF29DE520D9914C708153E53511F1A1C942C5255218C326CA780E655F97C6DD8C56F4356C5F662AB30B82E574DA7C71069444659593EB4FCECFD2A5A59F4189D591BAB5B859B36D8787CD25B998F8742CA1EBE5E21A0FE761D6FB160512A75FCA112566105431335728A61633D26D46C8A0086639503663D93016564C94A065C1ED7E26405C1A49709F09E63BD50ECC22EE43B66E591E56787B0E56651F7F5EA6DD15D6C7D7A6368331AC46C69F80FF3F8E42A6D197B2E7D64A21C6C5DFA7C24E3EB056FF57AF9E9229D797049ABEED7CC617D70E9873A91F8441A76C9F2476C04B159778D8499C06CB4EB77C1888E4F5BFF3D78D1B89901B4979378094C688B993CA67AAD4108FB73A6447B290D4381E00BA47B49CC626C9C8EF57C497B5ED9CEBC337D49E69A9C8B0E5C7F71F815572F37498129C596E682034D81695293796EA8FD82817A2E01B6E34783F9E00360AAE59885314A2993806A6B864931921DD1377F87611A0030751D6F886DDBD0D6C4CD3E89910633AD9A8EB8893DBE6CBC8EA5F88A55BA5F7B17CEF58A0DBD88D1B55A6F90513520EDEF995891757604E07B4DF091812DE2CE18BBEE92D975B4F5A0F7A79239C080587B7C1193399EF257FD533294DDEDD623D23F6594DD7BB2F61B457D95AD31F166AF3FF9952540F045AC5B2096A192FF528A346396ED038319EB9F4595AD804DD93F445398F16B1AD135B8089AB5077C418803849A8194D973AB65FC9C553BF027CF5C6F9D35DA059B5881029FA5A0BE4D4B48EF9FE53E0A89CD1A4DA2C5C629A27D5783A29D0A580BF25EFAA281958B33047262A4F93CF0624330E6A4A9F263190A154AA611BE9DEE84FF47A8F97EE94A8D5C99A919D8D093615792AE99D5512F75A04BAF1D31EB61ED07F1AF85070DC926576FB0D55B6558698B3BB1B90268E08FE573B229FBB6BB195910B2B9512DDEBECD95B5C932BA50366E20B62955380593E651B7C9D47908817975BB313B899B8CF3C1BBD57FD448595B2ABC695EEB2F291BE9BC353191F19D4849BD7D552821F6753EBD49E207059F7797BDB13717EB8FB33FC04D0C535B929714C1DD04E3BBAF8EE6C1ED784EDB7B6B29C17D5DE836754C82C21D44F98B9144B2C279656DCD138A22C4A5AF5AA08B9CC4C4A9125A8E663D81C46DB849A762EB3DC575BE483852C206C71186400F76F0AFC775C1F9447C669EC75D9138A03863B2C899EFA416D3E817C8D54EC0770735DEC7C5682CBD5B0813C769B1BDA83FC423CD6D3EFDFEC74CCCCD29209B0E6111E8CDCDC6D152F31ACBCF09FBE33BBEF37AD06D163213F33003D10DF2184A47E154D261E942BEAE90CCD555FF2584ABC5F4D651907A743D174DD77594DB4EBAA25DD8F9C796F5DD4A54D9D1EAB31DF52A85D98DE9D6E12FD253DA91F0BA0A9FC9CADABD3562CAC52E04DB95B99AE048EF65DC89DBD64DE0D28FDCE59223F1514C7DDE11326FFE7D64D3DF59B246DFF6CDF5E0415D2418EAA8BDE035C18AFB1B7E1EE209735292BB9BC1E239D63744A281EAE35D174CA02AC346E5C9BFF44AFE62B3E66503E5109F540EE7B94A13C0E92599E7459D65E9638E4AE93523E5FEE9D6A5E9798AB49CB63971EAE1DD5771A371E3EAB5AE9D3A293EB0E98D9493A1EA16AEEB017196B68DA052EAADD0DEAA685012F0F50E7E67040DFDF171C274AAC4231EF46182F66118D410F5E168AA9C55AB36F6599C3F40593AE9F69DA93B2B2D3536F9052088036888CCF9313A676230ED6DFAA9CB024121D0D1FB95B70606915525FD71C73796D75E25FE717240AACF7E1DFE8501FB630EA753FF8D184303CF9B82FF0DDD5928246A1D00D2288B241DA6A200126B7061A9A9BE00CEDBD7614DCBB402DECCE8FC1CD44303329EA6047285F00246BB5A2FF1134A05C26CE498758ECC061AC5D069096564087E84E9F5F5A1D00866C9EF203C551E082AD9231BE1D9BA08FA873B4B6ADB4E0ACACEE0EEC929430B9627959A56C7790C0EB351BCB62D3B0CBEECCCC04096780CB6D6657807ED2010A2E245874714B0107A6430BC1D8B54122A38B925E0D1B512022B1128310B62146EB130D35FFFE3141AD4E6C151AE6316368EDDEBBA180B1882C05A02F10EFA185ADC474AEAC245199E1F2E091BB1CE180AB05AA6725F9F1D261C774C0ABF0320BAE92996D8D465225AD6027F42E68322CAEA4261B53B30241E35E6C0B8CDA324B619995022AE2825CEC4819F9F98BC2622A0C08780117D27BAF32CB4945B8B26DE1B02EABBC6CF27423F65F8F4577D286631F208F7ED2329CE0414AC84CE0D2BB2A9A8D14A74C92B6E0CDEA198D2203056BB9AB101AE8C30FA9250AD10BDA93352247E9CEF34203462E22D37388D5E35C6782B7532FF02363AC7E1DFF02AEC362AF9CA45E28AF1372A19A545B1679C37EE305535143BC8383EE41CE78B4CC638A2F6C6FDB09385397EA05C046678403B9ABA194E5208F03BC2A902C2E45AAE3BAE880CA49E58493D0AFBF4447AF5923D9EE81951E728913FDA58B0D0244677416608F8CF32C89341D6D0743F38EDAD446E089FB970234645CADE2FE6BD977847C2BF772B46E9AE472EC580B37A840347C6ADE164946AC9479EBBF64114D5094AB652CBDD001FD34B1A975F8169D9154C467D65EC37D6824B72271AFF76B7694CDE7264281775EF4D4A9B9260DA8F674EFAE261F152E6EE4C1E24BDD7BBE5424FFA8632C0551D8D4BE6B24962CC5D4E518A0F59A27B94CC546E8D7B59DE58FE54BABD7999AEC4AE55AEB5D38EACD88D544AC673DB46DF3C57421061C1E53F0A592E0DF4C865B6D85B422D06241284015C6E1BAD53EADB255E8A1982CA5052575F22F3F9E44B8FE35F9A654F45B2AFA25F229BF088F25D2062229AA335C62EB3636E9951D8F82440653AA82D22F69E1966EAB678548B8D15674EC66D8A6DD918683ED2DB58EAEAF66806E606D31B2674688275554CD537D76AD69DA4D44951076BAA86E6AF7CEE346B6A26A3CC6518356D2ABCEC45C70BB06D6241AACE3F665C6E069B6E311D26A66E2EF064141342BF6F86627BCD965AC17026BD2621457BB171160344C518438B71EACA63DF6D7423730A45073B5D38B373B2B5694074A88C73769EEDE7EC810073CAD9367CFA7B2976BE3E3BA51FA7EE769A298C832DEBF07832C62792BCB07D7A0E06AA15F179FF7ACA868D9C48C82F7AFA33ECBEB953007CEEA25A8E04C88A7DCA7825DBFDECB07D867CA7F606F4A27E028E5FD2DFCE6880C2E766526C214D820AA793B8BBD93E83CA586B9EA8C65D89BE1DDBD9B4C9AA8B92E616E72AF0D18B2617469A27D1F28C0E5EBA562358618DBE4B5D5C5BD02291DEC91EAC8B12B292CE2B97BE8FBB6A93325E3D39CA56BE93CA9F3B91BBB8A493E6D5749533DA8C929AC50F022CE9A6937E3B792980D4DE94529348CB1D484E9626648B8572CFC395CA30C9F2ECAB359756BB0E02F4223E93C29724415CBAD0925EF73B2E71B8D7947EE26382053DB199C644A4591013ED9CDA99BE70B3C7489D6A5C3D0DAE6CCC9DDE9682E4A8219BA09692C036314EB4A0EE4470472E46CDA1B2D3529EC553C0A1EEEF182E52CBA3A3D2BE8D96050820A5CA3E34382859A4AA4287C6A393A5CCAB46BDC85E7BCD1DAC7E3DD7C3259CF1AC5A37D27FB7E34FADAA1E48523F375DAE8EDA0C489E41B5AFDE8283ADE99BA0AF6213BED6BE72F9AFCA063E3E4BF335B0C6A697364AE8C5B56E09CF4A56DC1CB6C6E9313A333656B70ADD5639B468B5B766ED20487A5368B99EBA7246A755EDB90EF9233682D8ACBB46B981EE655D6ABC9AA348491BAFB2BC8A26B4C50C1E53BDD6EFE08F005500BC42622C4AA5942CBEE6877F7919D8EEBE4AE7830665202ABF3AEB1D9D5B2A87BE8EA9BFC22D6908C062A2ECF31CBC8CC076E5C02A6ED8F2C16699528EC98B97C2FE518FF9F35D10C48AF0D6452456E4C5729F2192944A88C5EEA623FDB32FB3C9AE9878B3979FFCCA92258E4AB8A926CE72A509D46A28DACEDAB019918A067BCFCE5AA706C643B9CFC61554E70A067BD08654443AD08A3BD13E12EAF4DD8CA7D186EC5AE181B0ADD37E8FC70E57FCDBD3C6E68A1C84AF94D49A7D3E790FB46BD45ECC55936AE3F0D516F88E08A15C75D9263F4195DFD453DCAA3E229976A4DBDEAA092D47B39E40E236B2B6E6494511E2D2A7E9D5436857E352E07C227E33CFE3AE6C7DD57FE884E3021BF0EA34955EE24E1BB06E47DE2EE38EBBAFECC5F45CE3AA9F899F9B2C88E446795288316C7FE7D26A3431D45D89E85A0456D5036001EC6E0AD12CA9139FEC4A190A42B84E73F0CE5BD8FF700BA1F11E0CD9852D6C04F3A28DD2F427095FF62A676B0D7245A8F6669396AC6556E0F64AD3CAE6570197F71A7A03AC716D1AF8C2DB9D992456ECF906A901647AEC21FB561AB150BBFFD2FDFE9643BD3C768DFF0655A25700EB2001178FCEFB70C2FD01775061317B7C6303A7FE02C0521BE40547E12B319D1A90068FEEB4C9B123DC07874FFDEC947FE907ABED11F375C15F0B73B2DF1DC53B430C53D865AA819EB70D8358A39EC10FF60ED7E35CFD0F7358105399C520C1803810A7B6A378AE71CA10B374A82BA05503126BB1E4E40CE7C013EFC0BD81B5816E142B00F178980314142B275AE328240E15936862290E2B77167B37E77E0DD840199753518009736319878E29B34F18831AE7097125E93B5F1B3336EC06C0326D1E437A11A36BB5DE20A3B8CBAC25399A21FB68BA1FDCF65622B79ED91FA196DE235BD670320AB5E423CF36FBF31F699B26EB92E8418A72C9264755758BD770CB268329890FDD3A1A2937557784100D212A1BC6638ADCCEBC2AAF4C9C5FB936F72BA70A22A22773942B3774257985435F2D870364D3D487B32DAB76BF78B74B182D9BEF2C34CB0FF42E37D44AC91D66472F7BD05D746FB44630DB51B1CD53137330B75B3CAAC5C68A3327E336C5F66C0C349F200E4A002AC234F78FB73DDDE6E9347788C9F69C8F98370BEBE71146C73D380770D581715FA037C7EBA5C175B27C394B5BA3EE6D8C0C3BF74BFA779F34C63B9B8C6432066B8C3B577A95D3B5B5CE3C2B1976DFDC29003E77DEFEC30D299C3E0B9AED19B8B74C3E1F82B4FA146DD43F1F3F323AE6AC3B4083407D9B1690DE3FCB9609C6CFA3FC403B73E5DC7D8215425F738B7355F8E84593947699F9B06546CF964B51517CE546F3FD9C2CC4D99549F3CC57456C556E4C83B94A77A4140D4C8F8359065BEA244E6FD798F750D8624E03CED8F809C581510B8D3E251AEBE8530FA6FE57E912705417A67910027FBB554399FB060BDFE056E3234530A85467588BE1F64EDEFB4C594FA74D8F2F669C59ABD34B1B25F4E25A37FB6044A143905AD7761024BD29B45C4FB1A12743AE245D33A6705B78382D5FD39DE58D1351245FD3EC881E4242C3619B5CA52FDFA4D262DB5CD53A8935876207CF10494A25C462772700E0882A67635BE350D85D89A664777A42F48F72106593BD234B091B01671FE72112570203677F26C1375065D868834489C6D37249695F68235AFCC33269D34F14ABCF10CF6AA360938C1E962B6B0F0BBD8A46ADF76BDBD6057DA2D2036BA37C478450AEBA6C1302717701585E6D03DC7A9988B8816C87461AC7C123E76B23C541F3E67FE46F03BF824C92C66671BFC2FC2D681AB2723F28D559EC7968734B700BC9BD4DAC73835B355E10DA77753B5C1FA31910BD751B09317449C34776931EF7A0360A8575CBD19B172BE0E97D5F9947AC6B23767EC7A8B0987DBEB181D3C5A58CA33248830F2BFCE82FE341846FC8AA061C23B484B3BAD06DEA9804853BF3DAE01346D485D3A0AC7A4F016084E7E684B2F8F65E86F7FAEC8D42170C877FB213B4A37F0E869B8F01E90C5D2789BFB19D7B3689FB8FCF7518F0A8680490477A7B0925FC2C90E3237140F1C6649133EF36DBB5DC1C9067AC9E2B44824092FB98326BF511C192DB023C6BA5CF5C935741C6D871C22D936F1DEDFA032322927FECF1A5C1A2F1947B2A2C3347A1AF944F60F88AEBFFC595EF2C2BD2E581219757BB0E2D6FE3FB979F29F095BE100798B36B8528042E349873D15E70FEF15C99EB4E97FE4602269AD33557AC0C21129A2FCBBE83CF7E039C770EF4AF5E9F8C9CB37E750A8787399DABBCC4BE707B329C0B56D46468C48DA0BF2A47A696BC0EA343B39C73D2A380A73F53F627F3EF69A8BBE4BE37D96FBDA7D370B7C50B8FE2ABF344D5B03AAF05ACFB707B27EF7DA6AC277D30A2D02148AD6B6ECE7A13F989AD4BCE189FC48343AEDB9B8A7B1AD5C5ACAFE7AF55DF3204AF839879F785B943ACE7365CA3447AD5AFC3CC4AC206B871B31BDE91A5C48D80B38F5A6F10EFEA87B4934565856FA218B6D3BECE95ABF3D5B64FE604FC0999F1B6FB4B6724C34719B8B77335C111DECBB813F475643F1E34BAFB3656CDCF1488BABBFCDF1EFA855DBA7FFD05548D48CBBBBF0DCC7E0426C2BC131DFFAE25ADD7BCD31B462697F1AABDEF45313DD8270CBEE707E056242CD8BE13B84AD4B764DFBE93AF7DDE172DE1BFD7502BAFE8FDCEBFC337C5E34EDFE8C087FB01139A6702C14FE252C65119A4C1073BDE03DC8DBCC377F552572E26F6C597C472D5A181BEC6EF95563F0C9BBEC7AFCC7AEC1FDB23C83BE44ECEA71BA5C8572063EC78E196C9B7159699A3D057CA27A8F661C99049CB7F9615E9B2C090CBAB4CD4221EC681CB6B187E4FA4B4ACCEB79C3F95F16F67CF27D7093F0F4FE8CFBF2B6A3234E246D05F1A31CA13B119D0772FE95D9A6C4CD117FBB941E9244CD12BE03923B1430FD2FBF1881FE9CB5BCF7F8F703B61C247D0635873AA17FCA4D34367B086764C24D4F3156939ED73E2D4C31CFFBE92AA66D2F3CCBCFB82DC21D6F327DFE3B34422D82B5FFF9EF1CE0CDB0B1006608827A5DCCFB517FBBFD1FADE6B9B8E4E2BDABBDF13DF6F28857840E0EB389F49953BBDE03F9DDF7584F60DE103A0BD5551A024E0EBAAACE1CEE3C2E287272FFFDFBD56E3934AAB1F864DDFE35740A69AB4E3A8E3ABB69265D1A857E4EF1E35FE76FA45E7E3ECE0EB8D8F8FE7BF8D18E5C9D80CE8BBEB849FC727F4E7DF60BD6743F873E75B51834C442880EB5F5F77A1710D78ED1F508CBABCFF90F1FBE6D7ABEF82D8F29B4C7F17204F17F35B401007D01099F363510B9885180BF4EBD3A8310E9D73F573EA797F5D48F9F527EAFC496ECF7DF6DF960582C2A0A3F72B41DC02BB88B4F91FF5BCBF6EA4FCFA93BE83BF8CEAA6FB0B20E6DE183804FC7B59D5F023324AFC5B058AAC5DC4B1FE03 +hex(toString(any(x))): 0000E80700000000000000003388DB1D6772005049A73DC93E5F02C81E0744088A200314F27E1B5971930364D869BECF448F031CDDD168E4CB29077411F5BA7C00F407804BB94A3F3CD3072C939B0D1D3EB909881BEFFD2451F50B484D762EDB911F0C04E5010A912E450DE41B4B4A3AB035109893DF4178AA3C1054F3282BFBF14212A07B5D6581ACC013007D9FEE1E0F8915CCA0B5EBFB1F7718204F2A35AD8EF3181C07DE613913491944F14F8B54993D1A2072A8D4244BF31BD0C03DB2FF5D7E1CE030658D8BD9B21D144791B8E9C9F01F5075B76266DD8D1F3CC48B0E8F286021F4D1A7BE9744CB217C68DAA3E018A22230E7515676E3852440500BD59FFE5E25283B03FD36BB272950CA6B2E0B4EA729B0FEF3A84F9F362A7894927596A3F32A646361A6BFFEC72934A621D4411DC729A4C5EED6E842B62B087E299C3163C22CCC59154FB6E6E7297C82EFDFD9DDE52D847AA5EE3AFE5A2E08B15DE5110D9C2ED013FA43E8B22A2FA0295EB9A235C32E7C5D560C305704324CA3C48C79FD5C359472143538356F3608954262FF95CA36203AEB36210B023730E550A9C996E637A08CF4BEEED85438DC847CF58217CC383CD0C5364C006339C05EE651503651364C81030873356C3BBC61CA1A17B3653B2856DE4D6457073CA89B21D104A35D3DA0373EC903EADD3DBC6F9131BE6D393EAC15D69A04C5A43E20270839DE6FE23F9C8F2271D393E13FA09AF855B41707417CCE2EFF95ED3842F0D8F5872027CA433C20CBAF80A1F0434825A131DFE53D46708F06756079724AFC7FC1A0FB06A74A2451CF11916C144B20A016AA3FFDBD4A505302A6FA1ABF4AD48D312115BAA04D4469E536D347494E5CD31E7DA2BCF14EFC85B244A4CDBE50643414666367E452F4E211B93B40AD52E85C227129871E54B8FDE7519F3E6D54F0765C53B0608355347B7179511B9F553C2CCBC234FCBC56B4F52C0233138E56F42925EB2C47E755C837FA4CC4402A5808C1F462B0193356B405D2D843BA8F57445B4238E41C9E59504F917D29171A5AD0E17F2828CBBD5D40D732E5E11EB75EAC2FA504DE0C435F6077A91FE550DF5F68184FCE3B2D445FD058667AC7C7BD60340DD3482544726164837D791E1E0C60E074B649753CED62ACA7A1BABA2ABC620CF32A2385376564F00534FCA8CEC16480BBAC1860AE0864985CCB79565DA765F8F998DE0636336628A548497A052E660C4C33C606183567AC30433B08A0D368C0F7C45D0A8EC96980CD7EE97A6FD96C04A2E2DCA675776DBC2B85C4FE2B956D408D07821AE074713061AA6A287690717CE24439A0BB4B7270970423EA85D872BC014CCF3597C3722CF61CBCD263A373B8543943E50CB075E4642F36A1F603764C41256A0D114078ACDC6026F74C2D790C6E7C9287D4BB7B785B65EC17E3D47D783F69ECBEBB037E589156377B1FBF7EE488F0B15CCA297E10D033A24E51227FB417D1EFFAC80A808852871FB946E180E85ED7F8B0936281681394D9D15CC582246ED6BCAAE94C839048766CEBB408855C41CFC51D7543875C4991F3925E90879899BEE72F691788D85A7664CF924D8B28DFA886710EBB8B00E3508739E1B88C903F91E258207B8DE44BA164B649A08DC8BD5FCCFB2FF18E84223C7E7C7C018F8C3D2B04AFBBA38E145242FC586C379024EAF5D843EBD590CCA775B10D7534904474057F658254924C33800B96E78F939CEB97ED5EC53F94D457D517D3C50B961C51C3DA7F6D0C96609C263FDAFE539794A596BB013EA697345F44ED62F2A397945C20A19B5502998486CCDFB0307D9FC465D026315105A01CB376841B8655A0643659CB98AB05A2B8F185EFFB6819A3DC349F7344D8D4A3B8B29F5A129735A5D81A4D2186F4DDA760BB3AC0088D09A8649DCB2CE99906A944EAB6B4E525B0A9E07AF332DD895DAB5C46E9405E7046ACDCA43C0ACAC223AD0488B2E1199E1FAEA8AF87E8923321B0B0073F66EE89DDB0EC63B4048C178AB10C2349DFE2708AB168D195FC62B2E4B154D7AE66DBB96EB31C64E59B08F834B31CCADD8E335F43B46073457CC7B1B3B4E8FC87793C7295B68CAA9E31FB7B6CB7EC773EB53CCB00B80094F83D69444FB924BD42C3ADFD81B9F00986DCB86052B9C0F13E32CBD16BBAE0C3FF5050967BBB8006671F81757ABC343B18DBFFC224BC0054D7A1751573BDA08E5054DD359BBDEC0A5131E6EBA7BDF85E4A09BC1986BEC0DBEBD1326DDFBDDC5BE3FC0AA6CFBFF4624B73C181A6C034B1CCF48E8F7BC1680745A5EFD895C31803D817E32941C51C008E6943D307C6DCFCD5A43A6EC2C6BC3F0C321ADD12C67C0B6F1BE3C0BFC6ACB659C4DFD49CC70C4D8FF695171AC954E3FBA05EB67DCA1C4AEBD65CDE68CAACD676166177CECBF0C0A6EC5FA229CCF8F1D4F2641EC2CC004B9192F40A5CCC18734EE20E637FCC6019723B849E55CCF04AC9BD54A4F8CBE8EC5EA8364AFFCFE82EA6A62AA282D0606916DF2DAE03D1309F742741F632D2C805162D1F9423D4800EF63A27CB48D5F897AAD1B6E0E0D58C9646F6DF92BED6E0D72EA8BB7055D7880908049FEC0DD87870E9D2A45C7AD8C4EA3C8480BCBADD9851A086BC9A49DF3C5F5A80CA6985DF3C5BC218E0415BE0904C21189E394EE0687CD853CC7188E0D02A4FD353C64ADF64566CE9A67A2CE2D4B92E1B7F5F8CE220A98484CDEEEBE2F0BE86E6786FFFE3B4B26A11FAAE15E47894C7BEDBE846E614397EADC06DBEE68000FA83B45307E8F0790CA5A3702AE930DAD55C53CD14E9806B0EB046582EEB74312828AD516CEB6C6927550AD6E6ECBCA42E80883FFCED48E4FAA9C64577F0601D7047F35790F1F887DF8DC2C65CF2980B26509561A3F264CD4F7D3D48A1F2F03D323C0D6FB1F300D16E141EB57DF45085C80FA0DDA2F4CC35B0A844382BF5880D542B62F3FEF59498CCCD4A153DF60C4224AE3F3001F50819AA70A24C9EF7F41563FB718751F86007BF338286FEF83840476F7F3CE4F97844B51C899015FB94CD56A86B468DFEB80F30417DAC8FFF28DD6A05ABAF48007DAC4A1A3F33E7FFF40C1493658345010166747E0EEAA101199EDAA9C412240031551413A13035030533054A8720080631DDAC79D5D39906216E9296AE2533072173297E7BB9B60785CC59428E607908815A100E5E4BD2094590ECD8D669110AB9ED2C2BBB4CDD0CA578A19461CFBB0D29736D932B87890E554592294BF4BA0E8905D3D9286C0612B97A596B33875012BDFB3AB7BE738312510D01F55DE3E71321CC6958542F4A14A5C12DB3D5F6E715DDBE510DE31C761701E4127C9D77AB17D549F05F9A808C179D16D077EE77E2187D4E66C9E0F5F71899F1BF5692ED331AB1BC74ADC6B0C21A7D728EF30526631CD1DD0C272904F81D61D5F1F4F71F9A20916329814441BF20F1FD2CFDD188B62315DC535E434F60244D393294CBBAF726A53E7722F7714927CD92DEEBDD72A1277DD92431E62EA728458E62B3B5FD252DF913A8DB0268012E755149927EA1D12E1D8DD629B5ED122FC5F97CF2E5C7F12FCDAE7EE48166D230397AC33C3C160433198FFE8905FD0A34B9629D4B39CAFC34119E76B7A7E4B537EDDF846CA8D4EB38BD174A7CF818773951337DE1668F913AD5509E2A024E173BF517F87FF868983BD5C4E5A8C48FFD3B85DB9F7E9E14BE3C258694460322683DED90D2914C7CE53D09E86CD01B402D3EB97BC13BDA84C13EF56B479722F7C63C896708F84A2D52403D1E93FD13B5C740850C6B7A50FE7F414591B0DDA01A9D4015EBDCD521631B424DAB809A300ADB42A976D7075B06DD4121569700EDAAB94469E20BDF77D13246B92E4BA7869F6F47216ABACA596D4649CDA42C5EB3D17D49717D79C05CA8CE4A314CBFA2B4B22C4BC9B5D8DD0C71FE4AA1EDF3A6DD70124D2D9FBC4D9ACDB94D1976DF64EA147C4E894C3AFDE8508A4D911152E32D6F3151392B496741A572525D3671DBFF29DE520D9914C708153E53511F1A1C942C5255218C326CA780E655F97C6DD8C56F4356C5F662AB30B82E574DA7C71069444659593EB4FCECFD2A5A59F4189D591BAB5B859B36D8787CD25B998F8742CA1EBE5E21A0FE761D6FB160512A75FCA112566105431335728A61633D26D46C8A0086639503663D93016564C94A065C1ED7E26405C1A49709F09E63BD50ECC22EE43B66E591E56787B0E56651F7F5EA6DD15D6C7D7A6368331AC46C69F80FF3F8E42A6D197B2E7D64A21C6C5DFA7C24E3EB056FF57AF9E9229D797049ABEED7CC617D70E9873A91F8441A76C9F2476C04B159778D8499C06CB4EB77C1888E4F5BFF3D78D1B89901B4979378094C688B993CA67AAD4108FB73A6447B290D4381E00BA47B49CC626C9C8EF57C497B5ED9CEBC337D49E69A9C8B0E5C7F71F815572F37498129C596E682034D81695293796EA8FD82817A2E01B6E34783F9E00360AAE59885314A2993806A6B864931921DD1377F87611A0030751D6F886DDBD0D6C4CD3E89910633AD9A8EB8893DBE6CBC8EA5F88A55BA5F7B17CEF58A0DBD88D1B55A6F90513520EDEF995891757604E07B4DF091812DE2CE18BBEE92D975B4F5A0F7A79239C080587B7C1193399EF257FD533294DDEDD623D23F6594DD7BB2F61B457D95AD31F166AF3FF9952540F045AC5B2096A192FF528A346396ED038319EB9F4595AD804DD93F445398F16B1AD135B8089AB5077C418803849A8194D973AB65FC9C553BF027CF5C6F9D35DA059B5881029FA5A0BE4D4B48EF9FE53E0A89CD1A4DA2C5C629A27D5783A29D0A580BF25EFAA281958B33047262A4F93CF0624330E6A4A9F263190A154AA611BE9DEE84FF47A8F97EE94A8D5C99A919D8D093615792AE99D5512F75A04BAF1D31EB61ED07F1AF85070DC926576FB0D55B6558698B3BB1B90268E08FE573B229FBB6BB195910B2B9512DDEBECD95B5C932BA50366E20B62955380593E651B7C9D47908817975BB313B899B8CF3C1BBD57FD448595B2ABC695EEB2F291BE9BC353191F19D4849BD7D552821F6753EBD49E207059F7797BDB13717EB8FB33FC04D0C535B929714C1DD04E3BBAF8EE6C1ED784EDB7B6B29C17D5DE836754C82C21D44F98B9144B2C279656DCD138A22C4A5AF5AA08B9CC4C4A9125A8E663D81C46DB849A762EB3DC575BE483852C206C71186400F76F0AFC775C1F9447C669EC75D9138A03863B2C899EFA416D3E817C8D54EC0770735DEC7C5682CBD5B0813C769B1BDA83FC423CD6D3EFDFEC74CCCCD29209B0E6111E8CDCDC6D152F31ACBCF09FBE33BBEF37AD06D163213F33003D10DF2184A47E154D261E942BEAE90CCD555FF2584ABC5F4D651907A743D174DD77594DB4EBAA25DD8F9C796F5DD4A54D9D1EAB31DF52A85D98DE9D6E12FD253DA91F0BA0A9FC9CADABD3562CAC52E04DB95B99AE048EF65DC89DBD64DE0D28FDCE59223F1514C7DDE11326FFE7D64D3DF59B246DFF6CDF5E0415D2418EAA8BDE035C18AFB1B7E1EE209735292BB9BC1E239D63744A281EAE35D174CA02AC346E5C9BFF44AFE62B3E66503E5109F540EE7B94A13C0E92599E7459D65E9638E4AE93523E5FEE9D6A5E9798AB49CB63971EAE1DD5771A371E3EAB5AE9D3A293EB0E98D9493A1EA16AEEB017196B68DA052EAADD0DEAA685012F0F50E7E67040DFDF171C274AAC4231EF46182F66118D410F5E168AA9C55AB36F6599C3F40593AE9F69DA93B2B2D3536F9052088036888CCF9313A676230ED6DFAA9CB024121D0D1FB95B70606915525FD71C73796D75E25FE717240AACF7E1DFE8501FB630EA753FF8D184303CF9B82FF0DDD5928246A1D00D2288B241DA6A200126B7061A9A9BE00CEDBD7614DCBB402DECCE8FC1CD44303329EA6047285F00246BB5A2FF1134A05C26CE498758ECC061AC5D069096564087E84E9F5F5A1D00866C9EF203C551E082AD9231BE1D9BA08FA873B4B6ADB4E0ACACEE0EEC929430B9627959A56C7790C0EB351BCB62D3B0CBEECCCC04096780CB6D6657807ED2010A2E245874714B0107A6430BC1D8B54122A38B925E0D1B512022B1128310B62146EB130D35FFFE3141AD4E6C151AE6316368EDDEBBA180B1882C05A02F10EFA185ADC474AEAC245199E1F2E091BB1CE180AB05AA6725F9F1D261C774C0ABF0320BAE92996D8D465225AD6027F42E68322CAEA4261B53B30241E35E6C0B8CDA324B619995022AE2825CEC4819F9F98BC2622A0C08780117D27BAF32CB4945B8B26DE1B02EABBC6CF27423F65F8F4577D286631F208F7ED2329CE0414AC84CE0D2BB2A9A8D14A74C92B6E0CDEA198D2203056BB9AB101AE8C30FA9250AD10BDA93352247E9CEF34203462E22D37388D5E35C6782B7532FF02363AC7E1DFF02AEC362AF9CA45E28AF1372A19A545B1679C37EE305535143BC8383EE41CE78B4CC638A2F6C6FDB09385397EA05C046678403B9ABA194E5208F03BC2A902C2E45AAE3BAE880CA49E58493D0AFBF4447AF5923D9EE81951E728913FDA58B0D0244677416608F8CF32C89341D6D0743F38EDAD446E089FB970234645CADE2FE6BD977847C2BF772B46E9AE472EC580B37A840347C6ADE164946AC9479EBBF64114D5094AB652CBDD001FD34B1A975F8169D9154C467D65EC37D6824B72271AFF76B7694CDE7264281775EF4D4A9B9260DA8F674EFAE261F152E6EE4C1E24BDD7BBE5424FFA8632C0551D8D4BE6B24962CC5D4E518A0F59A27B94CC546E8D7B59DE58FE54BABD7999AEC4AE55AEB5D38EACD88D544AC673DB46DF3C57421061C1E53F0A592E0DF4C865B6D85B422D06241284015C6E1BAD53EADB255E8A1982CA5052575F22F3F9E44B8FE35F9A654F45B2AFA25F229BF088F25D2062229AA335C62EB3636E9951D8F82440653AA82D22F69E1966EAB678548B8D15674EC66D8A6DD918683ED2DB58EAEAF66806E606D31B2674688275554CD537D76AD69DA4D44951076BAA86E6AF7CEE346B6A26A3CC6518356D2ABCEC45C70BB06D6241AACE3F665C6E069B6E311D26A66E2EF064141342BF6F86627BCD965AC17026BD2621457BB171160344C518438B71EACA63DF6D7423730A45073B5D38B373B2B5694074A88C73769EEDE7EC810073CAD9367CFA7B2976BE3E3BA51FA7EE769A298C832DEBF07832C62792BCB07D7A0E06AA15F179FF7ACA868D9C48C82F7AFA33ECBEB953007CEEA25A8E04C88A7DCA7825DBFDECB07D867CA7F606F4A27E028E5FD2DFCE6880C2E766526C214D820AA793B8BBD93E83CA586B9EA8C65D89BE1DDBD9B4C9AA8B92E616E72AF0D18B2617469A27D1F28C0E5EBA562358618DBE4B5D5C5BD02291DEC91EAC8B12B292CE2B97BE8FBB6A93325E3D39CA56BE93CA9F3B91BBB8A493E6D5749533DA8C929AC50F022CE9A6937E3B792980D4DE94529348CB1D484E9626648B8572CFC395CA30C9F2ECAB359756BB0E02F4223E93C29724415CBAD0925EF73B2E71B8D7947EE26382053DB199C644A4591013ED9CDA99BE70B3C7489D6A5C3D0DAE6CCC9DDE9682E4A8219BA09692C036314EB4A0EE4470472E46CDA1B2D3529EC553C0A1EEEF182E52CBA3A3D2BE8D96050820A5CA3E34382859A4AA4287C6A393A5CCAB46BDC85E7BCD1DAC7E3DD7C3259CF1AC5A37D27FB7E34FADAA1E48523F375DAE8EDA0C489E41B5AFDE8283ADE99BA0AF6213BED6BE72F9AFCA063E3E4BF335B0C6A697364AE8C5B56E09CF4A56DC1CB6C6E9313A333656B70ADD5639B468B5B766ED20487A5368B99EBA7246A755EDB90EF9233682D8ACBB46B981EE655D6ABC9AA348491BAFB2BC8A26B4C50C1E53BDD6EFE08F005500BC42622C4AA5942CBEE6877F7919D8EEBE4AE7830665202ABF3AEB1D9D5B2A87BE8EA9BFC22D6908C062A2ECF31CBC8CC076E5C02A6ED8F2C16699528EC98B97C2FE518FF9F35D10C48AF0D6452456E4C5729F2192944A88C5EEA623FDB32FB3C9AE9878B3979FFCCA92258E4AB8A926CE72A509D46A28DACEDAB019918A067BCFCE5AA706C643B9CFC61554E70A067BD08654443AD08A3BD13E12EAF4DD8CA7D186EC5AE181B0ADD37E8FC70E57FCDBD3C6E68A1C84AF94D49A7D3E790FB46BD45ECC55936AE3F0D516F88E08A15C75D9263F4195DFD453DCAA3E229976A4DBDEAA092D47B39E40E236B2B6E6494511E2D2A7E9D5436857E352E07C227E33CFE3AE6C7DD57FE884E3021BF0EA34955EE24E1BB06E47DE2EE38EBBAFECC5F45CE3AA9F899F9B2C88E446795288316C7FE7D26A3431D45D89E85A0456D5036001EC6E0AD12CA9139FEC4A190A42B84E73F0CE5BD8FF700BA1F11E0CD9852D6C04F3A28DD2F427095FF62A676B0D7245A8F6669396AC6556E0F64AD3CAE6570197F71A7A03AC716D1AF8C2DB9D992456ECF906A901647AEC21FB561AB150BBFFD2FDFE9643BD3C768DFF0655A25700EB2001178FCEFB70C2FD01775061317B7C6303A7FE02C0521BE40547E12B319D1A90068FEEB4C9B123DC07874FFDEC947FE907ABED11F375C15F0B73B2DF1DC53B430C53D865AA819EB70D8358A39EC10FF60ED7E35CFD0F7358105399C520C1803810A7B6A378AE71CA10B374A82BA05503126BB1E4E40CE7C013EFC0BD81B5816E142B00F178980314142B275AE328240E15936862290E2B77167B37E77E0DD840199753518009736319878E29B34F18831AE7097125E93B5F1B3336EC06C0326D1E437A11A36BB5DE20A3B8CBAC25399A21FB68BA1FDCF65622B79ED91FA196DE235BD670320AB5E423CF36FBF31F699B26EB92E8418A72C9264755758BD770CB268329890FDD3A1A2937557784100D212A1BC6638ADCCEBC2AAF4C9C5FB936F72BA70A22A22773942B3774257985435F2D870364D3D487B32DAB76BF78B74B182D9BEF2C34CB0FF42E37D44AC91D66472F7BD05D746FB44630DB51B1CD53137330B75B3CAAC5C68A3327E336C5F66C0C349F200E4A002AC234F78FB73DDDE6E9347788C9F69C8F98370BEBE71146C73D380770D581715FA037C7EBA5C175B27C394B5BA3EE6D8C0C3BF74BFA779F34C63B9B8C6432066B8C3B577A95D3B5B5CE3C2B1976DFDC29003E77DEFEC30D299C3E0B9AED19B8B74C3E1F82B4FA146DD43F1F3F323AE6AC3B4083407D9B1690DE3FCB9609C6CFA3FC403B73E5DC7D8215425F738B7355F8E84593947699F9B06546CF964B51517CE546F3FD9C2CC4D99549F3CC57456C556E4C83B94A77A4140D4C8F8359065BEA244E6FD798F750D8624E03CED8F809C581510B8D3E251AEBE8530FA6FE57E912705417A67910027FBB554399FB060BDFE056E3234530A85467588BE1F64EDEFB4C594FA74D8F2F669C59ABD34B1B25F4E25A37FB6044A143905AD7761024BD29B45C4FB1A12743AE245D33A6705B78382D5FD39DE58D1351245FD3EC881E4242C3619B5CA52FDFA4D262DB5CD53A8935876207CF10494A25C462772700E0882A67635BE350D85D89A664777A42F48F72106593BD234B091B01671FE72112570203677F26C1375065D868834489C6D37249695F68235AFCC33269D34F14ABCF10CF6AA360938C1E962B6B0F0BBD8A46ADF76BDBD6057DA2D2036BA37C478450AEBA6C1302717701585E6D03DC7A9988B8816C87461AC7C123E76B23C541F3E67FE46F03BF824C92C66671BFC2FC2D681AB2723F28D559EC7968734B700BC9BD4DAC73835B355E10DA77753B5C1FA31910BD751B09317449C34776931EF7A0360A8575CBD19B172BE0E97D5F9947AC6B23767EC7A8B0987DBEB181D3C5A58CA33248830F2BFCE82FE341846FC8AA061C23B484B3BAD06DEA9804853BF3DAE01346D485D3A0AC7A4F016084E7E684B2F8F65E86F7FAEC8D42170C877FB213B4A37F0E869B8F01E90C5D2789BFB19D7B3689FB8FCF7518F0A8680490477A7B0925FC2C90E3237140F1C6649133EF36DBB5DC1C9067AC9E2B44824092FB98326BF511C192DB023C6BA5CF5C935741C6D871C22D936F1DEDFA032322927FECF1A5C1A2F1947B2A2C3347A1AF944F60F88AEBFFC595EF2C2BD2E581219757BB0E2D6FE3FB979F29F095BE100798B36B8528042E349873D15E70FEF15C99EB4E97FE4602269AD33557AC0C21129A2FCBBE83CF7E039C770EF4AF5E9F8C9CB37E750A8787399DABBCC4BE707B329C0B56D46468C48DA0BF2A47A696BC0EA343B39C73D2A380A73F53F627F3EF69A8BBE4BE37D96FBDA7D370B7C50B8FE2ABF344D5B03AAF05ACFB707B27EF7DA6AC277D30A2D02148AD6B6ECE7A13F989AD4BCE189FC48343AEDB9B8A7B1AD5C5ACAFE7AF55DF3204AF839879F785B943ACE7365CA3447AD5AFC3CC4AC206B871B31BDE91A5C48D80B38F5A6F10EFEA87B4934565856FA218B6D3BECE95ABF3D5B64FE604FC0999F1B6FB4B6724C34719B8B77335C111DECBB813F475643F1E34BAFB3656CDCF1488BABBFCDF1EFA855DBA7FFD05548D48CBBBBF0DCC7E0426C2BC131DFFAE25ADD7BCD31B462697F1AABDEF45313DD8270CBEE707E056242CD8BE13B84AD4B764DFBE93AF7DDE172DE1BFD7502BAFE8FDCEBFC337C5E34EDFE8C087FB01139A6702C14FE252C65119A4C1073BDE03DC8DBCC377F552572E26F6C597C472D5A181BEC6EF95563F0C9BBEC7AFCC7AEC1FDB23C83BE44ECEA71BA5C8572063EC78E196C9B7159699A3D057CA27A8F661C99049CB7F9615E9B2C090CBAB4CD4221EC681CB6B187E4FA4B4ACCEB79C3F95F16F67CF27D7093F0F4FE8CFBF2B6A3234E246D05F1A31CA13B119D0772FE95D9A6C4CD117FBB941E9244CD12BE03923B1430FD2FBF1881FE9CB5BCF7F8F703B61C247D0635873AA17FCA4D34367B086764C24D4F3156939ED73E2D4C31CFFBE92AA66D2F3CCBCFB82DC21D6F327DFE3B34422D82B5FFF9EF1CE0CDB0B1006608827A5DCCFB517FBBFD1FADE6B9B8E4E2BDABBDF13DF6F28857840E0EB389F49953BBDE03F9DDF7584F60DE103A0BD5551A024E0EBAAACE1CEE3C2E287272FFFDFBD56E3934AAB1F864DDFE35740A69AB4E3A8E3ABB69265D1A857E4EF1E35FE76FA45E7E3ECE0EB8D8F8FE7BF8D18E5C9D80CE8BBEB849FC727F4E7DF60BD6743F873E75B51834C442880EB5F5F77A1710D78ED1F508CBABCFF90F1FBE6D7ABEF82D8F29B4C7F17204F17F35B401007D01099F363510B9885180BF4EBD3A8310E9D73F573EA797F5D48F9F527EAFC496ECF7DF6DF960582C2A0A3F72B41DC02BB88B4F91FF5BCBF6EA4FCFA93BE83BF8CEAA6FB0B20E6DE183804FC7B59D5F023324AFC5B058AAC5DC4B1FE03 diff --git a/tests/queries/0_stateless/02418_aggregate_combinators.reference b/tests/queries/0_stateless/02418_aggregate_combinators.reference index 499b98621478..5ed2ca5fd35d 100644 --- a/tests/queries/0_stateless/02418_aggregate_combinators.reference +++ b/tests/queries/0_stateless/02418_aggregate_combinators.reference @@ -1,8 +1,8 @@ -{1:'\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç'} -{1:[{1:['\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç','\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç']}]} -[['\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç','\0\nuð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhä\rÓH%uULÕsE|Ç'],[]] -[[{1:'\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç'}],[]] -['\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç'] +{1:'\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'} +{1:[{1:['\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:','\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:']}]} +[['\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:','\0\0\nuð¨h�GÕñô÷š ‘\rÓH%DradÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'],[]] +[[{1:'\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'}],[]] +['\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'] 10 {1:10} {1:[{1:[10,10]}]} diff --git a/tests/queries/0_stateless/02428_combinators_with_over_statement.reference b/tests/queries/0_stateless/02428_combinators_with_over_statement.reference index 55be3f35cb18..27d1e82bac18 100644 --- a/tests/queries/0_stateless/02428_combinators_with_over_statement.reference +++ b/tests/queries/0_stateless/02428_combinators_with_over_statement.reference @@ -1,50 +1,50 @@ -{1:'\0ñwRß'} -{1:'\0Dõ@='} -{1:'\07¬Ž�'} -{1:'\0ÁâÞ²'} -{1:'\0"Qœ'} -{1:'\0V\'ˆã'} -{1:'\0Òâ\0¨'} -{1:'\0£_Ç'} -{1:'\0qµ4h'} -{1:'\0gÔ7'} -['\0ñwRß'] -['\0Dõ@='] -['\07¬Ž�'] -['\0ÁâÞ²'] -['\0"Qœ'] -['\0V\'ˆã'] -['\0Òâ\0¨'] -['\0£_Ç'] -['\0qµ4h'] -['\0gÔ7'] -['\0éZÂ','\0\0'] -['\0â4nÿ','\0\0'] -['\0“Þ“','\0\0'] -['\0¹�ð1','\0\0'] -['\0‹_ Date: Wed, 19 Aug 2026 17:03:42 +0000 Subject: [PATCH 042/272] Fix `SEMI` join `ON` expression access --- src/Analyzer/Resolve/IdentifierResolver.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index 586d2a3c994e..c5ace8ab0761 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1328,6 +1328,13 @@ SemiAntiJoinSideChecker::SemiAntiJoinSideChecker( /// other side would expose a sibling table outside the current ON expression. if (resolving_join_on_expression) { + if (resolving_join_on_expression == &join_node) + { + skip_left = false; + skip_right = false; + return; + } + if (skip_left && joinSubtreeContains(join_node.getLeftTableExpressionNode().get(), resolving_join_on_expression)) skip_left = false; if (skip_right && joinSubtreeContains(join_node.getRightTableExpressionNode().get(), resolving_join_on_expression)) From 9414f59c2cbdd5ebae5c48e4b6dcb4a98b3884c8 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 20 Aug 2026 03:04:56 +0000 Subject: [PATCH 043/272] Address review: keep the 32-bit main sample, add a small sample of 64-bit hashes https://github.com/ClickHouse/ClickHouse/pull/115393#issuecomment-5345457212 Instead of storing all hashes as 64-bit values (which doubled the memory of every `uniq` state), the main sample keeps the historical 32-bit format bit for bit, so below two billion elements the estimates, the memory usage and the serialized state are exactly as before. In addition, a small sample of full 64-bit hashes is maintained (this is what state version 1 carries): only one hash in 262144 enters it and it is bounded by 16384 values (256 KiB), so it stays empty or tiny for normal states - about 4 values per million distinct elements - and the estimate switches to it at about two billion, where the 32-bit sample degrades. The variant suggested in the review - grow in 32 bits up to ~1 billion, then convert the state to 64-bit hashes accepting the loss for the consumed data - turns out not to work with parallel and distributed aggregation: every partial state stays below the threshold, so no 64-bit information is ever captured, and the merged estimate saturates around 9.5e10 regardless of the real cardinality (measured exactly that on `numbers_mt(50000000000)` with an implementation of it). The wide sample is therefore maintained from the first insertion. In the batched insertion path the candidates are detected branchlessly and inserted after the loop, keeping the hot loop free of calls: the instruction count of `SELECT uniq(number) FROM numbers(100000000)` is within 0.1% of master. Since the main sample is unchanged, all the mixed-version scenarios (verified against a real 26.7 server: raw state columns and partial aggregation states, in both directions) give exactly the value the old server computes natively, and the previously updated test references of the estimates below two billion are reverted to their master values. SELECT uniq(number) FROM numbers_mt(50000000000) returns 50090681348 (+0.18%) instead of 18446743978444128518. Co-Authored-By: Claude Fable 5 --- .../interfaces/specs/NativeFormat.mdx | 4 +- .../interfaces/specs/NativeProtocol.mdx | 2 +- .../AggregateFunctionUniq.cpp | 11 +- .../AggregateFunctionUniq.h | 4 +- src/AggregateFunctions/UniquesHashSet.h | 620 ++++++++++++------ .../tests/gtest_uniques_hash_set.cpp | 276 +++++--- .../00973_uniq_non_associativity.reference | 4 +- ...ray_slice_of_aggregate_functions.reference | 2 +- .../01890_state_of_state.reference | 16 +- .../02418_aggregate_combinators.reference | 10 +- ..._combinators_with_over_statement.reference | 100 +-- .../03821_uniq_speedup_boundaries.reference | 2 +- .../03821_uniq_speedup_boundaries.sql | 4 +- ..._uniq_state_version_64bit_hashes.reference | 4 +- .../05023_uniq_state_version_64bit_hashes.sql | 13 +- ...ate_version_parallel_marshalling.reference | 4 +- ...q_64bit_sample_above_two_billion.reference | 1 + ...26_uniq_64bit_sample_above_two_billion.sql | 9 + 18 files changed, 702 insertions(+), 384 deletions(-) create mode 100644 tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.reference create mode 100644 tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql diff --git a/docs/reference/interfaces/specs/NativeFormat.mdx b/docs/reference/interfaces/specs/NativeFormat.mdx index e4530a83354f..5bb4768983b6 100644 --- a/docs/reference/interfaces/specs/NativeFormat.mdx +++ b/docs/reference/interfaces/specs/NativeFormat.mdx @@ -312,7 +312,7 @@ Each feature sits behind a `DBMS_MIN_REVISION_WITH_*` threshold. The writer emit | V2 `Dynamic` / `JSON` serialization | `DBMS_MIN_REVISION_WITH_V2_DYNAMIC_AND_JSON_SERIALIZATION` | `54473` | `Dynamic` and `JSON`/`Object` use V1 serialization (with the `max_dynamic_*` parameter) instead of V2. | | Aggregate-function versioning | `DBMS_MIN_REVISION_WITH_AGGREGATE_FUNCTIONS_VERSIONING` | `54452` | `AggregateFunction` state is written without an embedded version. | | `skip_degree` in `quantileDeterministic` state | `DBMS_MIN_REVISION_WITH_QUANTILE_DETERMINISTIC_SKIP_DEGREE` | `54491` | `AggregateFunction(quantileDeterministic, ...)` states (and the `quantiles`/`median` spellings of the same function) are written at state version `0`, without the trailing `UInt8 skip_degree`. See the note under [type aliases](#type-aliases). | -| 64-bit hashes in `uniq` state | `DBMS_MIN_REVISION_WITH_UNIQ_STATE_VERSION_1` | `54492` | `AggregateFunction(uniq, ...)` states are written at state version `0`: no leading flags byte, and the stored hash values truncated to 32 bits. See the note under [type aliases](#type-aliases). | +| 64-bit hash sample in `uniq` state | `DBMS_MIN_REVISION_WITH_UNIQ_STATE_VERSION_1` | `54492` | `AggregateFunction(uniq, ...)` states are written at state version `0`: no leading flags byte and no trailing sample of 64-bit hashes. See the note under [type aliases](#type-aliases). | | `out_of_order_buckets` in `BlockInfo` | `DBMS_MIN_REVISION_WITH_OUT_OF_ORDER_BUCKETS_IN_AGGREGATION` | `54480` | `BlockInfo` field ID `3` is not written (see [BlockInfo](#blockinfo)). | | Parallel block marshalling (`DETACHED`) | `DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING` | `54478` | Columns are never wrapped in a `ColumnBLOB`; no `DETACHED` / `DETACHED_OVER_SPARSE` kinds appear (see [kind_stack](#kind-stack-and-sparse-encoding)). | | `DateTime(tz)` type parameter | `DBMS_MIN_REVISION_WITH_TIME_ZONE_PARAMETER_IN_DATETIME_DATA_TYPE` | `54337` | The timezone parameter is dropped from the `type` string — `DateTime('UTC')` is announced as bare `DateTime`. | @@ -1029,7 +1029,7 @@ So a `Point` column is decoded exactly as `Tuple(Float64, Float64)` (rendering a Two related types are **not** aliases. They are valid `Native` column types — a client can receive an `AggregateFunction` column from a `-State` combinator or distributed aggregation, for instance — but each carries its own specialized payload that is outside the scope of this page: -- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54492`: version `1` prepends a flags byte and stores full 8-byte hash values (or 4-byte ones when bit `0` of the flags marks a state that carries only 32 bits of hash information, inherited from version `0` data it was merged with), while a version `0` state keeps the legacy layout — no flags byte, hash values truncated to 4 bytes. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. +- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54492`: version `1` prepends a flags byte to the version-`0` layout and, when bit `0` of the flags is set, appends a small additional sample of full 64-bit hashes (`UInt8 skip_degree`, `VarUInt count`, then the 8-byte values), which keeps the estimate accurate at cardinalities above two billion; a version `0` state carries the 32-bit main sample only. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. - `QBit(T, N[, stride])` stores a vector with its bit planes transposed for vector-search workloads; its on-wire stream layout (group-major `FixedString` bit-plane streams, `element_size * (N / stride)` of them with an explicit `stride`) and its binary type encoding (tag `0x36`, or `0x37` `QBitWithStride` when `stride != N`) are documented on the [`QBit` data type page](/reference/data-types/qbit) and in the [binary type encoding](/reference/data-types/data-types-binary-encoding) reference, so a `Native` reader does not have to recover them from the C++ source. diff --git a/docs/reference/interfaces/specs/NativeProtocol.mdx b/docs/reference/interfaces/specs/NativeProtocol.mdx index 1f5b8c43b7b3..8689a71684fa 100644 --- a/docs/reference/interfaces/specs/NativeProtocol.mdx +++ b/docs/reference/interfaces/specs/NativeProtocol.mdx @@ -155,7 +155,7 @@ When a feature is active, its fields **must** be present on the wire. The protoc | INTERSERVER_CURRENT_ROLES | 54488 | ClientInfo | Adds a trailing `current_roles` optional String list to ClientInfo (`[UInt8 present]` then, if `1`, `[VarUInt count][String]*count`), and, for inter-server queries, extends the `auth_hash` to cover the serialized list. Carries the initiator's active (enabled) role names so a secondary node scopes row policies the same way instead of falling back to the user's default roles. Only populated for inter-server queries and only when `push_external_roles_in_interserver_queries = 1`; external clients send `0` (absent). Required once negotiated version ≥ 54488 — omitting the presence byte desynchronizes the rest of the Query packet. | | HTTP_HANDLER_IN_CLIENT_INFO | 54490 | ClientInfo | Adds `http_handler_name` and `http_request_url` (both `String`) to the **HTTP** branch of ClientInfo, immediately after `http_referer`. They carry the name of the matched SQL-defined HTTP handler and the request URL, so that `currentHandler`/`currentRequestURL` and the `http_handler_name`/`http_request_url` `system.query_log` columns stay populated on remote shards for distributed handler queries. Written only when `query_interface = HTTP` (empty strings for a non-handler HTTP query); required once negotiated version ≥ 54490 — omitting it desynchronizes the rest of the Query packet. | | QUANTILE_DETERMINISTIC_SKIP_DEGREE | 54491 | Block (Column) | Bumps the state version of `AggregateFunction(quantileDeterministic, ...)` (and the `quantiles`/`median` spellings of it) from `0` to `1`, which appends a trailing `UInt8 skip_degree` to each serialized state. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states, so an older peer is unaffected. | -| UNIQ_STATE_VERSION_1 | 54492 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`, which stores full 64-bit hash values instead of ones truncated to 32 bits (fixing the overflow of the estimate at cardinalities above ten billion). A version `1` state starts with a flags byte: bit `0` set means the values carry only 32 bits of hash information (a state downgraded by contact with version `0` data) and are written as 4 bytes each; otherwise the values are 8 bytes each. The rest of the layout matches version `0`. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (with the stored hashes truncated to 32 bits), so an older peer is unaffected. | +| UNIQ_STATE_VERSION_1 | 54492 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`. A version `1` state starts with a flags byte, followed by the main sample in the version-`0` layout; when bit `0` of the flags is set, an additional small sample of full 64-bit hashes follows (`UInt8 skip_degree`, `VarUInt count`, then `count` 8-byte values) — it is thinned out to at most 16384 values and keeps the estimate accurate at cardinalities above two billion, where the 32-bit main sample degrades and used to overflow into garbage. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (the main sample only), so an older peer is unaffected. | ## Packet envelope {#packet-envelope} diff --git a/src/AggregateFunctions/AggregateFunctionUniq.cpp b/src/AggregateFunctions/AggregateFunctionUniq.cpp index 518914cd93ea..4ad084813c64 100644 --- a/src/AggregateFunctions/AggregateFunctionUniq.cpp +++ b/src/AggregateFunctions/AggregateFunctionUniq.cpp @@ -237,11 +237,12 @@ For the calculation state, the function uses a sample of element hash values up This algorithm is very accurate and very efficient on the CPU. When the query contains several of these functions, using `uniq` is almost as fast as using other aggregate functions. -The state keeps 64-bit hashes of the values, so the estimate stays accurate for cardinalities of hundreds of billions. -States written by servers older than 26.8 (state version 0) kept only 32 bits of each hash, -which capped the number of distinguishable values at around ten billion. -Merging with such a state - as well as storing the state into a table column created before the upgrade, -whose type keeps state version 0 - degrades the calculation to the legacy 32-bit accuracy. +The sample keeps 32-bit hashes, which cannot distinguish more than a few billion values, so in addition +a small heavily-thinned-out sample of full 64-bit hashes is maintained (about one hash per 262144 values, +bounded by 16384 hashes), and the estimate switches to it at around two billion. This keeps the estimate +accurate (within about 1%) for cardinalities of hundreds of billions and more, where servers older +than 26.8 returned garbage (issue #6078). States written by those servers carry no 64-bit sample, +so merging with them keeps only the 32-bit precision for the merged-in values. diff --git a/src/AggregateFunctions/AggregateFunctionUniq.h b/src/AggregateFunctions/AggregateFunctionUniq.h index af5ef4f58c52..3ed3c1b5909c 100644 --- a/src/AggregateFunctions/AggregateFunctionUniq.h +++ b/src/AggregateFunctions/AggregateFunctionUniq.h @@ -572,7 +572,7 @@ public: bool allocatesMemoryInArena() const override { return false; } - /// Version 1 of the `uniq` state keeps 64-bit hashes instead of 32-bit ones, + /// Version 1 of the `uniq` state carries an additional small sample of 64-bit hashes, /// which fixes the overflow of the estimate at cardinalities above ten billion (issue #6078). bool isVersioned() const override { return is_uniques_hash_set; } @@ -739,7 +739,7 @@ public: bool allocatesMemoryInArena() const override { return false; } - /// Version 1 of the `uniq` state keeps 64-bit hashes instead of 32-bit ones, + /// Version 1 of the `uniq` state carries an additional small sample of 64-bit hashes, /// which fixes the overflow of the estimate at cardinalities above ten billion (issue #6078). /// The versioning follows the same scheme as the single-argument `uniq` above, see the comments there. bool isVersioned() const override { return is_uniques_hash_set; } diff --git a/src/AggregateFunctions/UniquesHashSet.h b/src/AggregateFunctions/UniquesHashSet.h index c6d8f617c848..0a2e99c02871 100644 --- a/src/AggregateFunctions/UniquesHashSet.h +++ b/src/AggregateFunctions/UniquesHashSet.h @@ -11,7 +11,6 @@ #include #include -#include /** Approximate calculation of anything, as usual, is constructed according to the following scheme: @@ -28,7 +27,7 @@ /** Very simple hash-set for approximate number of unique values. * Works like this: * - you can insert UInt64; - * - before insertion, first the hash function UInt64 -> UInt64 is calculated; + * - before insertion, first the hash function UInt64 -> UInt32 is calculated; * - the original value is not saved (lost); * - further all operations are made with these hashes; * - hash table is constructed according to the scheme: @@ -42,22 +41,20 @@ * - the size() method returns an approximate number of elements that have been inserted into the set; * - there are methods for quick reading and writing in binary and text form. * - * Compatibility note. Historically the hashes were truncated to 32 bits, which capped the number - * of distinguishable elements at around ten billion: for larger cardinalities the estimate - * saturated and even resulted in undefined behavior in the correction formula (issue #6078). - * Now the full 64-bit hash is kept. States written in the legacy format (by old servers, - * or read from tables created before the change) identify elements by only the low 32 bits - * of the hash, and the discarded bits cannot be recovered. Such states are kept in a special - * "compat" mode: the stored values carry only 32 bits of information, and merging a 64-bit state - * with a compat state downgrades the former by truncating its hashes - the result is exactly - * the same as if the whole calculation had been done with 32-bit hashes. The same truncation - * is applied when a state is serialized for an old server. + * A 32-bit hash cannot distinguish more than a few billion elements: the estimate degrades quickly + * beyond a billion, and above ~30 billion the correction formula even resulted in undefined behavior + * (issue #6078). To keep the estimate accurate up to trillions, in addition to the main set a small + * "wide" set of full 64-bit hashes is maintained (state version 1). It is thinned out aggressively + * from the very beginning - only hashes with UNIQUES_HASH_SET_WIDE_INITIAL_SKIP_DEGREE zero + * low-order bits enter it - so it stays empty or tiny for the states where the main set is accurate + * (about 4 values per million distinct elements), and it is bounded by UNIQUES_HASH_SET_WIDE_MAX_SIZE + * values thinned out further. Once the wide set has enough values for its own estimate to be reliable + * (which happens at about two billion elements), size() switches to it. * - * In the compat mode, a 32-bit hash h is stored in the 64-bit cell as (h << 32) | h. - * With this encoding both halves of the cell equal h, so the bits used for the position - * in the hash table (the high-order ones) and the bits used for thinning (the low-order ones) - * coincide with what the legacy 32-bit implementation used, and no separate code path - * is needed for lookups and thinning. + * The wide set has to be maintained from the first insertion: the high bits of the hashes + * of the already consumed elements cannot be recovered later. In particular, a state written + * in the legacy format (state version 0) carries no wide set, so merging it into a state + * of the new format keeps only the 32-bit precision for the merged-in elements. */ /// The maximum degree of buffer size before the values are discarded @@ -69,11 +66,29 @@ /** The number of least significant bits used for thinning. The remaining high-order bits are used to determine the position in the hash table. * (high-order bits are taken because the younger bits will be constant after dropping some of the values) */ -#define UNIQUES_HASH_BITS_FOR_SKIP (64 - UNIQUES_HASH_MAX_SIZE_DEGREE) +#define UNIQUES_HASH_BITS_FOR_SKIP (32 - UNIQUES_HASH_MAX_SIZE_DEGREE) /// Initial buffer size degree #define UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE 4 +/** Only hashes with this many zero low-order bits enter the wide set, so that it stays + * negligibly small while the main set is accurate: one hash in 262144 passes. + */ +#define UNIQUES_HASH_SET_WIDE_INITIAL_SKIP_DEGREE 18 + +/// The maximum degree of the wide set buffer size before the values are thinned out further. +#define UNIQUES_HASH_SET_WIDE_MAX_SIZE_DEGREE 15 + +/// The maximum number of elements of the wide set: 16384 values take 256 KiB. +#define UNIQUES_HASH_SET_WIDE_MAX_SIZE (1ULL << (UNIQUES_HASH_SET_WIDE_MAX_SIZE_DEGREE - 1)) + +/** The estimate is taken from the wide set when it has at least this many values, i.e. starting + * from about UNIQUES_HASH_SET_WIDE_MIN_SIZE_FOR_ESTIMATE * 2^UNIQUES_HASH_SET_WIDE_INITIAL_SKIP_DEGREE, + * about two billion elements: at that point the statistical error of the wide estimate (about 1%) + * meets the degrading precision of the 32-bit one. + */ +#define UNIQUES_HASH_SET_WIDE_MIN_SIZE_FOR_ESTIMATE 8192 + /** This hash function is not the most optimal, but UniquesHashSet states counted with it, * stored in many places on disks (in many companies), so it continues to be used. @@ -88,21 +103,42 @@ struct UniquesHashSetDefaultHash template -class UniquesHashSet : private HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt64)> +class UniquesHashSet : private HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt32)> { private: using Value = UInt64; - using HashValue = UInt64; - using Allocator = HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt64)>; + using HashValue = UInt32; + using Allocator = HashTableAllocatorWithStackMemory<(1ULL << UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE) * sizeof(UInt32)>; + + /** The set of full 64-bit hashes with the same open addressing scheme as the main set. + * It is small (it starts with much stronger thinning), so it does not need stack memory, + * and the whole set, including this header, lives in a single lazy heap allocation. + */ + struct WideSet + { + UInt32 m_size = 0; /// Number of elements + UInt8 size_degree = 0; /// The size of the table as a power of 2 + UInt8 skip_degree = UNIQUES_HASH_SET_WIDE_INITIAL_SKIP_DEGREE; + bool has_zero = false; + UInt64 * buf = nullptr; + + size_t bufSize() const { return 1ULL << size_degree; } + size_t maxFill() const { return 1ULL << (size_degree - 1); } + size_t mask() const { return bufSize() - 1; } + size_t place(UInt64 x) const { return (x >> (64 - UNIQUES_HASH_MAX_SIZE_DEGREE)) & mask(); } + bool good(UInt64 hash) const { return hash == ((hash >> skip_degree) << skip_degree); } + }; UInt32 m_size; /// Number of elements UInt8 size_degree{}; /// The size of the table as a power of 2 UInt8 skip_degree; /// Skip elements not divisible by 2 ^ skip_degree bool has_zero; /// The hash table contains an element with a hash value of 0. - bool compat_32bit; /// The stored values carry only 32 bits of hash information (see the comment above). HashValue * buf{}; + /// nullptr while no hash has passed the wide thinning (i.e. for most of the states). + WideSet * wide = nullptr; + #ifdef UNIQUES_HASH_SET_COUNT_COLLISIONS /// For profiling. mutable size_t collisions; @@ -132,14 +168,9 @@ private: /// The value is divided by 2 ^ skip_degree bool good(HashValue hash) const { return hash == ((hash >> skip_degree) << skip_degree); } - HashValue hash(Value key) const { return static_cast(Hash()(key)); } + static UInt64 fullHash(Value key) { return static_cast(Hash()(key)); } - /// The representation of a 32-bit hash in the compat mode (see the comment above). - static HashValue replicate32(HashValue x) - { - HashValue low = x & 0xFFFFFFFFULL; - return (low << 32) | low; - } + HashValue hash(Value key) const { return static_cast(Hash()(key)); } /// Delete all values whose hashes do not divide by 2 ^ skip_degree void rehash() @@ -302,40 +333,167 @@ private: } } - template - void readImpl(DB::ReadBuffer & rb) + UInt8 wideSkipDegree() const { return wide ? wide->skip_degree : UNIQUES_HASH_SET_WIDE_INITIAL_SKIP_DEGREE; } + + static WideSet * allocWideSet(UInt8 new_size_degree) { - if (m_size <= 1) + /// The header and the table are placed in a single allocation. + size_t bytes = sizeof(WideSet) + (1ULL << new_size_degree) * sizeof(UInt64); + WideSet * res = new (HashTableAllocator().alloc(bytes)) WideSet; + res->size_degree = new_size_degree; + res->buf = reinterpret_cast(reinterpret_cast(res) + sizeof(WideSet)); + return res; + } + + static void freeWideSet(WideSet * set) + { + if (set) + HashTableAllocator().free(set, sizeof(WideSet) + set->bufSize() * sizeof(UInt64)); + } + + void freeWide() + { + freeWideSet(wide); + wide = nullptr; + } + + /// Copy the wide set with a table of the given size (the values are reinserted, not memcpy'd). + static WideSet * resizeWideSet(const WideSet * old, UInt8 new_size_degree) + { + WideSet * res = allocWideSet(new_size_degree); + res->m_size = old->m_size; + res->skip_degree = old->skip_degree; + res->has_zero = old->has_zero; + for (size_t i = 0; i < old->bufSize(); ++i) + if (old->buf[i]) + wideReinsertImpl(*res, old->buf[i]); + return res; + } + + static void wideReinsertImpl(WideSet & set, UInt64 x) + { + size_t place_value = set.place(x); + while (set.buf[place_value]) { - for (size_t i = 0; i < m_size; ++i) + ++place_value; + place_value &= set.mask(); + } + set.buf[place_value] = x; + } + + /// Delete all values of the wide set whose hashes do not divide by 2 ^ skip_degree. + static void wideRehash(WideSet & set) + { + for (size_t i = 0; i < set.bufSize(); ++i) + { + if (set.buf[i]) { - StoredValue x = 0; - DB::readBinaryLittleEndian(x, rb); - insertValueRead(static_cast(x)); + if (!set.good(set.buf[i])) + { + set.buf[i] = 0; + --set.m_size; + } + else if (i != set.place(set.buf[i])) + { + UInt64 x = set.buf[i]; + set.buf[i] = 0; + wideReinsertImpl(set, x); + } } } - else - { - auto hs = std::make_unique(m_size); - rb.readStrict(reinterpret_cast(hs.get()), m_size * sizeof(StoredValue)); - for (size_t i = 0; i < m_size; ++i) + for (size_t i = 0; i < set.bufSize() && set.buf[i]; ++i) + { + if (i != set.place(set.buf[i])) { - DB::transformEndianness(hs[i]); - insertValueRead(static_cast(hs[i])); + UInt64 x = set.buf[i]; + set.buf[i] = 0; + wideReinsertImpl(set, x); } } } - void insertValueRead(HashValue x) + void wideInsert(UInt64 x) { - if (compat_32bit) - x = replicate32(x); + /// The caller may have checked the value against a stale (smaller) thinning mask + /// for the sake of the hot loop, so the check is repeated here. + if (x & ((1ULL << wideSkipDegree()) - 1)) + return; + + if (!wide) + wide = allocWideSet(UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE); if (x == 0) - has_zero = true; - else - reinsertImpl(x); + { + wide->m_size += !wide->has_zero; + wide->has_zero = true; + return; + } + + size_t place_value = wide->place(x); + while (wide->buf[place_value] && wide->buf[place_value] != x) + { + ++place_value; + place_value &= wide->mask(); + } + + if (wide->buf[place_value] == x) + return; + + wide->buf[place_value] = x; + ++wide->m_size; + + if (unlikely(wide->m_size > wide->maxFill())) + { + if (wide->m_size > UNIQUES_HASH_SET_WIDE_MAX_SIZE) + { + while (wide->m_size > UNIQUES_HASH_SET_WIDE_MAX_SIZE) + { + ++wide->skip_degree; + wideRehash(*wide); + } + } + else + { + WideSet * new_set = resizeWideSet(wide, wide->size_degree + 1); + freeWide(); + wide = new_set; + } + } + } + + UInt64 wideSkipMask() const { return (1ULL << wideSkipDegree()) - 1; } + + /** Wide candidates have at least UNIQUES_HASH_SET_WIDE_INITIAL_SKIP_DEGREE zero low-order bits, + * and the thinning of the main set never exceeds 16 bits (at the degree of 16 the number + * of possible sampled 32-bit hashes is already down to the maximum size of the set), + * so every wide candidate passes good() of the main set, and it is enough to check + * the candidates on that rare path - the hot loops stay free of extra work. + * The check is a pre-filter on the truncated hash: it passes a superset of the candidates + * (an exact one while the wide thinning fits 32 bits), and `wideInsert` checks the full hash + * against the current mask itself - which also makes a stale pre-filter mask harmless. + */ + HashValue widePrefilterMask() const { return static_cast(wideSkipMask()); } + + /** With how many different elements, when randomly scattered across 2^32 buckets, + * the given number of non-empty buckets is obtained on average. + * This is the correction of the systematic error due to collisions of the 32-bit hashes. + */ + static size_t correctFor32BitCollisions(size_t res) + { + size_t p32 = 1ULL << 32; + + /** When the number of distinct elements is much larger than 2^32, almost all buckets are filled + * and `res` may reach (or, due to the pseudo-random remainder, exceed) 2^32, + * where the formula below is not defined (previously this resulted in undefined behavior + * when casting infinity or NaN to an integer). Saturate the estimate instead: + * 32 bits of a hash contain no information about larger cardinalities anyway + * (and the wide set takes over long before this point). + */ + if (res >= p32) + res = p32 - 1; + + return static_cast(round(static_cast(p32) * (log(static_cast(p32)) - log(static_cast(p32 - res))))); } @@ -345,8 +503,7 @@ public: UniquesHashSet() : // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - base class is an allocator with stack memory, initialized in alloc() m_size(0), skip_degree(0), - has_zero(false), - compat_32bit(false) + has_zero(false) { alloc(UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE); #ifdef UNIQUES_HASH_SET_COUNT_COLLISIONS @@ -355,10 +512,19 @@ public: } UniquesHashSet(const UniquesHashSet & rhs) // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - base class is an allocator with stack memory, initialized in alloc() - : m_size(rhs.m_size), skip_degree(rhs.skip_degree), has_zero(rhs.has_zero), compat_32bit(rhs.compat_32bit) + : m_size(rhs.m_size), skip_degree(rhs.skip_degree), has_zero(rhs.has_zero) { alloc(rhs.size_degree); memcpy(buf, rhs.buf, buf_size() * sizeof(buf[0])); + + if (rhs.wide) + { + wide = allocWideSet(rhs.wide->size_degree); + wide->m_size = rhs.wide->m_size; + wide->skip_degree = rhs.wide->skip_degree; + wide->has_zero = rhs.wide->has_zero; + memcpy(wide->buf, rhs.wide->buf, rhs.wide->bufSize() * sizeof(UInt64)); + } } UniquesHashSet & operator=(const UniquesHashSet & rhs) @@ -375,27 +541,37 @@ public: m_size = rhs.m_size; skip_degree = rhs.skip_degree; has_zero = rhs.has_zero; - compat_32bit = rhs.compat_32bit; memcpy(buf, rhs.buf, buf_size() * sizeof(buf[0])); + freeWide(); + if (rhs.wide) + { + wide = allocWideSet(rhs.wide->size_degree); + wide->m_size = rhs.wide->m_size; + wide->skip_degree = rhs.wide->skip_degree; + wide->has_zero = rhs.wide->has_zero; + memcpy(wide->buf, rhs.wide->buf, rhs.wide->bufSize() * sizeof(UInt64)); + } + return *this; } ~UniquesHashSet() { free(); + freeWide(); } void ALWAYS_INLINE insert(Value x) { - HashValue hash_value = hash(x); - if (unlikely(compat_32bit)) - hash_value = replicate32(hash_value); - + const HashValue hash_value = hash(x); if (!good(hash_value)) return; + if (unlikely((hash_value & widePrefilterMask()) == 0)) + wideInsert(fullHash(x)); + insertImpl(hash_value); shrinkIfNeed(); } @@ -409,13 +585,18 @@ public: requires std::is_invocable_r_v void insertMany(const SourceType * data, size_t size) { - /// States downgraded to the compat mode are rare and not worth a separate batch implementation. - if (unlikely(compat_32bit)) - { - for (size_t i = 0; i < size; ++i) - insert(Transform(data[i])); - return; - } + /** The candidates for the wide set (see the comment at `widePrefilterMask`) are only detected + * in the main loop - branchlessly, on the truncated hashes - and are inserted after it: + * a call inside the loop would make the compiler cautiously reload the fields of the set + * from memory all the time, slowing the loop down by tens of percent. + * A candidate is expected once per 262144 values, so the positions are collected + * into a small buffer; on its overflow (pathological input) the tail after the last + * recorded position is simply rescanned - insertion into the wide set is idempotent. + */ + std::array wide_candidate_positions; // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - only the first num_wide_candidates entries are read + size_t num_wide_candidates = 0; + + const HashValue wide_prefilter = widePrefilterMask(); size_t i = 0; while (i < size) @@ -428,10 +609,20 @@ public: /// We calculate place() even for !good() hashes to maximize data independence and enable better out-of-order execution. /// The extra work is negligible compared to the instruction level parallelization benefits. std::array hash_value; // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - filled by the loop below before read + size_t wide_candidates = 0; for (size_t j = 0; j < insert_many_batch_size; ++j) { hash_value[j] = hash(Transform(data[i + j])); + wide_candidates += !(hash_value[j] & wide_prefilter); } + + if (unlikely(wide_candidates)) + { + for (size_t j = 0; j < insert_many_batch_size; ++j) + if ((hash_value[j] & wide_prefilter) == 0 && num_wide_candidates < wide_candidate_positions.size()) + wide_candidate_positions[num_wide_candidates++] = static_cast(i + j); + } + i += insert_many_batch_size; std::array place_value_batch; // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - filled by the loop below before read @@ -474,6 +665,10 @@ public: else { const HashValue hash_value = hash(Transform(data[i])); + + if (unlikely((hash_value & wide_prefilter) == 0) && num_wide_candidates < wide_candidate_positions.size()) + wide_candidate_positions[num_wide_candidates++] = static_cast(i); + i++; if (!good(hash_value)) continue; @@ -484,10 +679,45 @@ public: /// We need to check shrink condition after each batch or single insert shrinkIfNeed(); } + + if (unlikely(num_wide_candidates)) + { + for (size_t k = 0; k < num_wide_candidates; ++k) + wideInsert(fullHash(Transform(data[wide_candidate_positions[k]]))); + + /// The buffer was full: rescan the tail after the last recorded position. + if (num_wide_candidates == wide_candidate_positions.size()) + { + for (size_t k = wide_candidate_positions.back() + 1; k < size; ++k) + { + const UInt64 full_hash = fullHash(Transform(data[k])); + if (unlikely((full_hash & wideSkipMask()) == 0)) + wideInsert(full_hash); + } + } + } } size_t size() const { + /** The wide set gives a reliable estimate once it has enough values; + * below that the 32-bit main set is more precise. + */ + if (wide && wide->m_size >= UNIQUES_HASH_SET_WIDE_MIN_SIZE_FOR_ESTIMATE) + { + size_t res = static_cast(wide->m_size) * (1ULL << wide->skip_degree); + + /** Pseudo-random remainder - in order to be not visible, + * that the number is divided by the power of two. + */ + res += (intHashCRC32(wide->m_size) & ((1ULL << wide->skip_degree) - 1)); + + /** Collisions of 64-bit hashes are negligible: their systematic error would reach + * just 0.003% at 10^15 distinct elements, far below the statistical error of the estimate. + */ + return res; + } + if (0 == skip_degree) return m_size; @@ -498,86 +728,20 @@ public: */ res += (intHashCRC32(m_size) & ((1ULL << skip_degree) - 1)); - /** With 64-bit hashes, the systematic error due to hash collisions is negligible: - * it reaches just 0.003% at 10^15 distinct elements, - * which is far below the statistical error of the estimate itself. - */ - if (!compat_32bit) - return res; - /** Correction of a systematic error due to collisions during hashing in UInt32. * `fixed_res(res)` formula * - with how many different elements of fixed_res, * when randomly scattered across 2^32 buckets, * filled buckets with average of res is obtained. */ - size_t p32 = 1ULL << 32; - - /** When the number of distinct elements is much larger than 2^32, almost all buckets are filled - * and `res` may reach (or, due to the pseudo-random remainder, exceed) 2^32, - * where the formula below is not defined (previously this resulted in undefined behavior - * when casting infinity or NaN to an integer). Saturate the estimate instead: - * for larger cardinalities the 32-bit state contains no information anyway. - */ - if (res >= p32) - res = p32 - 1; - - size_t fixed_res = static_cast(round(static_cast(p32) * (log(static_cast(p32)) - log(static_cast(p32 - res))))); - return fixed_res; + return correctFor32BitCollisions(res); } - /** Convert the state to the compat mode, where elements are identified by only the low - * 32 bits of the hash (as done by the legacy implementation). The stored values are truncated; - * values that collide after truncation are deduplicated exactly as the legacy implementation would do. - * This loses information and cannot be undone. - */ - void downgradeToCompat32() - { - if (compat_32bit) - return; - - compat_32bit = true; - - if (0 == m_size) - return; - - /// Truncated values are placed by different bits of the hash, so the table is rebuilt from scratch. - DB::PODArray values; - values.reserve(m_size); - for (size_t i = 0; i < buf_size(); ++i) - if (buf[i]) - values.push_back(replicate32(buf[i])); - - memset(buf, 0, buf_size() * sizeof(buf[0])); - m_size = has_zero ? 1 : 0; - - for (HashValue x : values) - { - /// A value with zero low 32 bits truncates to the zero hash. - if (x == 0) - { - m_size += !has_zero; - has_zero = true; - } - else - insertImpl(x); - } - } - - bool isCompat32() const { return compat_32bit; } + /// For tests. + size_t wideSetSize() const { return wide ? wide->m_size : 0; } void merge(const UniquesHashSet & rhs) { - /** If the other state was built (fully or partially) with 32-bit hashes, there is no way - * to recover the missing bits of its hashes. Downgrade this state to the compat mode - * to keep deduplication correct: the result is the same as if the whole calculation - * had been done with 32-bit hashes. - */ - if (rhs.compat_32bit && !compat_32bit) - downgradeToCompat32(); - - const bool need_truncation = compat_32bit && !rhs.compat_32bit; - if (rhs.skip_degree > skip_degree) { skip_degree = rhs.skip_degree; @@ -593,33 +757,41 @@ public: for (size_t i = 0; i < rhs.buf_size(); ++i) { - if (!rhs.buf[i]) - continue; - - HashValue x = need_truncation ? replicate32(rhs.buf[i]) : rhs.buf[i]; - - if (x == 0) + if (rhs.buf[i] && good(rhs.buf[i])) { - /// A truncated value with zero low 32 bits becomes the zero hash. - if (!has_zero) - { - has_zero = true; - ++m_size; - shrinkIfNeed(); - } - } - else if (good(x)) - { - insertImpl(x); + insertImpl(rhs.buf[i]); shrinkIfNeed(); } } + + if (rhs.wide) + { + if (rhs.wide->skip_degree > wideSkipDegree()) + { + if (!wide) + wide = allocWideSet(UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE); + wide->skip_degree = rhs.wide->skip_degree; + wideRehash(*wide); + } + + if (rhs.wide->has_zero && (!wide || !wide->has_zero)) + wideInsert(0); + + for (size_t i = 0; i < rhs.wide->bufSize(); ++i) + { + UInt64 x = rhs.wide->buf[i]; + if (x && (x & ((1ULL << wideSkipDegree()) - 1)) == 0) + wideInsert(x); + } + } } /** Two serialization formats exist. - * The legacy format (state version 0): skip_degree, then the number of values, then the values truncated to 32 bits. - * The new format (state version 1): a flags byte (bit 0 - the values carry only 32 bits of hash information), - * then skip_degree, then the number of values, then the values, 4 or 8 bytes each according to the flag. + * The legacy format (state version 0): skip_degree, then the number of values, then the 32-bit values + * of the main set. + * The new format (state version 1): a flags byte (bit 0 - a wide set follows the main set), + * then the main set in the legacy layout, then, if present, the wide set: its skip_degree, + * the number of values, and the 64-bit values. */ void write(DB::WriteBuffer & wb, bool use_legacy_format) const { @@ -629,19 +801,9 @@ public: /// A null `buf` here would indicate upstream state corruption (e.g. a double-destroyed state). chassert(buf); - if (use_legacy_format && !compat_32bit) - { - /// Serialization for an old server. The hashes have to be truncated to 32 bits; - /// the truncated values are exactly what an old server would have computed for the same elements. - UniquesHashSet compat_copy(*this); - compat_copy.downgradeToCompat32(); - compat_copy.write(wb, /*use_legacy_format=*/ true); - return; - } - if (!use_legacy_format) { - UInt8 flags = compat_32bit ? 1 : 0; + UInt8 flags = wide ? 1 : 0; DB::writeBinaryLittleEndian(flags, wb); } @@ -650,40 +812,44 @@ public: if (has_zero) { - if (compat_32bit) - DB::writeBinaryLittleEndian(static_cast(0), wb); - else - DB::writeBinaryLittleEndian(static_cast(0), wb); + HashValue x = 0; + DB::writeBinaryLittleEndian(x, wb); } for (size_t i = 0; i < buf_size(); ++i) - { if (buf[i]) - { - /// In the compat mode both halves of the stored value equal the 32-bit hash. - if (compat_32bit) - DB::writeBinaryLittleEndian(static_cast(buf[i]), wb); - else - DB::writeBinaryLittleEndian(buf[i], wb); - } + DB::writeBinaryLittleEndian(buf[i], wb); + + if (use_legacy_format || !wide) + return; + + DB::writeBinaryLittleEndian(wide->skip_degree, wb); + DB::writeVarUInt(wide->m_size, wb); + + if (wide->has_zero) + { + UInt64 x = 0; + DB::writeBinaryLittleEndian(x, wb); } + + for (size_t i = 0; i < wide->bufSize(); ++i) + if (wide->buf[i]) + DB::writeBinaryLittleEndian(wide->buf[i], wb); } void read(DB::ReadBuffer & rb, bool use_legacy_format) { has_zero = false; + freeWide(); - if (use_legacy_format) - { - compat_32bit = true; - } - else + bool has_wide = false; + if (!use_legacy_format) { UInt8 flags = 0; DB::readBinaryLittleEndian(flags, rb); if (flags & ~1) throw Poco::Exception("Cannot read UniquesHashSet: unknown flags."); - compat_32bit = flags & 1; + has_wide = flags & 1; } DB::readBinaryLittleEndian(skip_degree, rb); @@ -692,12 +858,6 @@ public: if (m_size > UNIQUES_HASH_MAX_SIZE) throw Poco::Exception("Cannot read UniquesHashSet: too large size_degree."); - /// The structure never produces skip_degree above 48 (at that point the number of possible - /// values passing the thinning is already below the maximum number of stored elements), - /// and shifting by 64 or more would be undefined behavior. - if (skip_degree >= 64) - throw Poco::Exception("Cannot read UniquesHashSet: too large skip_degree."); - free(); UInt8 new_size_degree = m_size <= 1 @@ -706,24 +866,81 @@ public: alloc(new_size_degree); - if (compat_32bit) - readImpl(rb); + if (m_size <= 1) + { + for (size_t i = 0; i < m_size; ++i) + { + HashValue x = 0; + DB::readBinaryLittleEndian(x, rb); + if (x == 0) + has_zero = true; + else + reinsertImpl(x); + } + } else - readImpl(rb); + { + auto hs = std::make_unique(m_size); + rb.readStrict(reinterpret_cast(hs.get()), m_size * sizeof(HashValue)); + + for (size_t i = 0; i < m_size; ++i) + { + DB::transformEndianness(hs[i]); + if (hs[i] == 0) + has_zero = true; + else + reinsertImpl(hs[i]); + } + } + + if (!has_wide) + return; + + UInt8 wide_skip_degree = 0; + DB::readBinaryLittleEndian(wide_skip_degree, rb); + size_t wide_size = 0; + DB::readVarUInt(wide_size, rb); + + if (wide_size > UNIQUES_HASH_SET_WIDE_MAX_SIZE) + throw Poco::Exception("Cannot read UniquesHashSet: too large wide set size."); + + /// The structure never produces such thinning degrees (at 48 the number of possible values + /// passing the thinning is already below the maximum number of stored values), + /// and larger shifts would be undefined behavior. + if (wide_skip_degree >= 64) + throw Poco::Exception("Cannot read UniquesHashSet: too large wide set skip_degree."); + + UInt8 wide_size_degree = wide_size <= 1 + ? UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE + : static_cast(std::max(UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE, static_cast(log2(wide_size - 1)) + 2)); + + wide = allocWideSet(wide_size_degree); + wide->m_size = static_cast(wide_size); + wide->skip_degree = wide_skip_degree; + + for (size_t i = 0; i < wide_size; ++i) + { + UInt64 x = 0; + DB::readBinaryLittleEndian(x, rb); + if (x == 0) + wide->has_zero = true; + else + wideReinsertImpl(*wide, x); + } } static void skip(DB::ReadBuffer & rb, bool use_legacy_format) { size_t size = 0; - bool values_32bit = true; + bool has_wide = false; if (!use_legacy_format) { UInt8 flags = 0; DB::readBinaryLittleEndian(flags, rb); if (flags & ~1) throw Poco::Exception("Cannot read UniquesHashSet: unknown flags."); - values_32bit = flags & 1; + has_wide = flags & 1; } rb.ignore(); @@ -732,7 +949,18 @@ public: if (size > UNIQUES_HASH_MAX_SIZE) throw Poco::Exception("Cannot read UniquesHashSet: too large size_degree."); - rb.ignore((values_32bit ? sizeof(UInt32) : sizeof(UInt64)) * size); + rb.ignore(sizeof(HashValue) * size); + + if (!has_wide) + return; + + rb.ignore(); + DB::readVarUInt(size, rb); + + if (size > UNIQUES_HASH_SET_WIDE_MAX_SIZE) + throw Poco::Exception("Cannot read UniquesHashSet: too large wide set size."); + + rb.ignore(sizeof(UInt64) * size); } #ifdef UNIQUES_HASH_SET_COUNT_COLLISIONS @@ -748,3 +976,7 @@ public: #undef UNIQUES_HASH_MAX_SIZE #undef UNIQUES_HASH_BITS_FOR_SKIP #undef UNIQUES_HASH_SET_INITIAL_SIZE_DEGREE +#undef UNIQUES_HASH_SET_WIDE_INITIAL_SKIP_DEGREE +#undef UNIQUES_HASH_SET_WIDE_MAX_SIZE_DEGREE +#undef UNIQUES_HASH_SET_WIDE_MAX_SIZE +#undef UNIQUES_HASH_SET_WIDE_MIN_SIZE_FOR_ESTIMATE diff --git a/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp index b479cbb9a120..caa18406ba4e 100644 --- a/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp +++ b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp @@ -32,24 +32,52 @@ TestSet deserialize(const std::string & data, bool use_legacy_format) return set; } -/// Craft a serialized state with the given skip degree and raw (already hashed) sampled values, -/// to test the estimate at cardinalities that would be too slow to reach by actual insertion. -template -std::string craftState(UInt8 skip_degree, const std::vector & values, bool use_legacy_format) +/// Craft a serialized state with the given raw (already hashed and thinned out) values, +/// to test the states of cardinalities that would be too slow to reach by actual insertion. +/// The wide set values are appended when present. +std::string craftState( + UInt8 skip_degree, + const std::vector & values, + bool use_legacy_format, + UInt8 wide_skip_degree = 0, + const std::vector & wide_values = {}) { WriteBufferFromOwnString out; if (!use_legacy_format) { - UInt8 flags = sizeof(StoredValue) == sizeof(UInt32) ? 1 : 0; + UInt8 flags = wide_values.empty() ? 0 : 1; writeBinaryLittleEndian(flags, out); } writeBinaryLittleEndian(skip_degree, out); writeVarUInt(values.size(), out); - for (StoredValue value : values) + for (UInt32 value : values) writeBinaryLittleEndian(value, out); + + if (!use_legacy_format && !wide_values.empty()) + { + writeBinaryLittleEndian(wide_skip_degree, out); + writeVarUInt(wide_values.size(), out); + for (UInt64 value : wide_values) + writeBinaryLittleEndian(value, out); + } return out.str(); } +/// Distinct pseudo-random values with the given number of zero low-order bits. +template +std::vector randomSampledValues(size_t count, UInt8 skip_degree, UInt64 seed) +{ + std::mt19937_64 rng(seed); + std::unordered_set values; + while (values.size() < count) + { + StoredValue value = static_cast(rng() << skip_degree); + if (value != 0) + values.insert(value); + } + return std::vector(values.begin(), values.end()); +} + } @@ -59,9 +87,10 @@ TEST(UniquesHashSet, ExactAtSmallCardinality) for (UInt64 i = 0; i < 50000; ++i) set.insert(i); - /// Below the thinning threshold the set stores all distinct 64-bit hashes, - /// and collisions among 50000 of them are next to impossible. - EXPECT_EQ(set.size(), 50000u); + /// Below the thinning threshold the set stores all distinct 32-bit hashes. + /// Among 50000 of them, an occasional collision is possible. + EXPECT_GE(set.size(), 49998u); + EXPECT_LE(set.size(), 50000u); } TEST(UniquesHashSet, EstimateAtModerateCardinality) @@ -72,6 +101,10 @@ TEST(UniquesHashSet, EstimateAtModerateCardinality) /// The typical error of the estimate is about 0.6%; allow 3%. EXPECT_NEAR(static_cast(set.size()), 10000000.0, 300000.0); + + /// The wide set holds about one value per 262144 distinct elements. + EXPECT_GE(set.wideSetSize(), 20u); + EXPECT_LE(set.wideSetSize(), 60u); } TEST(UniquesHashSet, MergeDeduplicates) @@ -84,119 +117,75 @@ TEST(UniquesHashSet, MergeDeduplicates) b.insert(i); a.merge(b); - EXPECT_EQ(a.size(), 60000u); + EXPECT_GE(a.size(), 59997u); + EXPECT_LE(a.size(), 60000u); } TEST(UniquesHashSet, RoundTripNewFormat) { TestSet set; - for (UInt64 i = 0; i < 300000; ++i) + for (UInt64 i = 0; i < 1000000; ++i) set.insert(i); std::string serialized = serialize(set, /*use_legacy_format=*/ false); TestSet restored = deserialize(serialized, /*use_legacy_format=*/ false); EXPECT_EQ(set.size(), restored.size()); + EXPECT_EQ(set.wideSetSize(), restored.wideSetSize()); /// The state must remain usable after the round trip. - for (UInt64 i = 300000; i < 400000; ++i) + for (UInt64 i = 1000000; i < 1100000; ++i) restored.insert(i); - EXPECT_NEAR(static_cast(restored.size()), 400000.0, 12000.0); + EXPECT_NEAR(static_cast(restored.size()), 1100000.0, 33000.0); } -TEST(UniquesHashSet, RoundTripLegacyFormat) +TEST(UniquesHashSet, LegacyFormatIsThePlainMainSet) { TestSet set; - for (UInt64 i = 0; i < 300000; ++i) + for (UInt64 i = 0; i < 1000000; ++i) set.insert(i); + /// The new format is a flags byte, the main set in the legacy layout, then the wide set. std::string legacy = serialize(set, /*use_legacy_format=*/ true); + std::string versioned = serialize(set, /*use_legacy_format=*/ false); + EXPECT_EQ(versioned[0], 1); + EXPECT_EQ(versioned.substr(1, legacy.size()), legacy); + EXPECT_GT(versioned.size(), 1 + legacy.size()); + + /// A state without the wide set differs only by the flags byte. + TestSet small; + for (UInt64 i = 0; i < 100; ++i) + small.insert(i); + if (small.wideSetSize() == 0) + { + std::string small_legacy = serialize(small, /*use_legacy_format=*/ true); + std::string small_versioned = serialize(small, /*use_legacy_format=*/ false); + EXPECT_EQ(small_versioned[0], 0); + EXPECT_EQ(small_versioned.substr(1), small_legacy); + } + + /// The legacy round trip drops the wide set but keeps the main one. TestSet restored = deserialize(legacy, /*use_legacy_format=*/ true); - - EXPECT_TRUE(restored.isCompat32()); - - /// The estimates of the downgraded and the original state may differ slightly - /// (only 32 bits of each hash remain), but at this cardinality both are accurate. - EXPECT_NEAR(static_cast(restored.size()), static_cast(set.size()), 10000.0); - - /// A state restored from the legacy format is written in the new format with the compat flag, - /// and survives that round trip as well. - std::string compat = serialize(restored, /*use_legacy_format=*/ false); - TestSet restored2 = deserialize(compat, /*use_legacy_format=*/ false); - EXPECT_TRUE(restored2.isCompat32()); - EXPECT_EQ(restored2.size(), restored.size()); + EXPECT_EQ(restored.wideSetSize(), 0u); + EXPECT_EQ(restored.size(), set.size()); } -TEST(UniquesHashSet, MergeWithLegacyStateDeduplicates) +TEST(UniquesHashSet, HugeCardinalityViaWideSet) { - /// Elements 20000..40000 are present on both sides: on the left with full 64-bit hashes, - /// on the right with truncated ones (after a round trip through the legacy format). - /// The merge must not double-count them. - TestSet a; - for (UInt64 i = 0; i < 40000; ++i) - a.insert(i); - - TestSet b; - for (UInt64 i = 20000; i < 60000; ++i) - b.insert(i); - TestSet b32 = deserialize(serialize(b, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); - - a.merge(b32); - EXPECT_TRUE(a.isCompat32()); - - /// The reference: the same union computed with truncated hashes from the start. - TestSet reference; - for (UInt64 i = 0; i < 60000; ++i) - reference.insert(i); - TestSet reference32 = deserialize(serialize(reference, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); - - /// At this cardinality the thinning has not started, so the estimate is exactly the number - /// of distinct 32-bit hashes, and it must match the reference exactly: had the merge failed - /// to deduplicate the common elements, the estimate would be larger by about 20000. - EXPECT_EQ(a.size(), reference32.size()); - - /// Merging in the other direction (a 64-bit state into a legacy state) gives the same result. - TestSet a2; - for (UInt64 i = 0; i < 40000; ++i) - a2.insert(i); - TestSet b32_copy = deserialize(serialize(b, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); - b32_copy.merge(a2); - EXPECT_EQ(b32_copy.size(), reference32.size()); -} - -TEST(UniquesHashSet, InsertIntoLegacyState) -{ - /// Inserting into a state restored from the legacy format must deduplicate - /// against the values that are already there. - TestSet set; - for (UInt64 i = 0; i < 30000; ++i) - set.insert(i); - - TestSet restored = deserialize(serialize(set, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); - for (UInt64 i = 0; i < 60000; ++i) - restored.insert(i); - - EXPECT_NEAR(static_cast(restored.size()), 60000.0, 100.0); -} - -TEST(UniquesHashSet, NoOverflowAtHugeCardinality) -{ - /// Simulate the state left by 10^11 distinct elements: with the thinning degree of 21, - /// about 10^11 / 2^21 sampled hashes remain, each with 21 zero low-order bits. + /// Simulate the state left by 10^11 distinct elements: the wide set thinned out to the degree 23 + /// keeps about 10^11 / 2^23 values; the main set is saturated and unusable. constexpr UInt64 cardinality = 100000000000ULL; - constexpr UInt8 skip_degree = 21; - constexpr size_t num_samples = cardinality >> skip_degree; + constexpr UInt8 wide_skip_degree = 23; + constexpr size_t num_wide = cardinality >> wide_skip_degree; - std::mt19937_64 rng(42); - std::unordered_set samples; - while (samples.size() < num_samples) - samples.insert(rng() << skip_degree); + /// (There are only 65535 nonzero multiples of 2^16 among the 32-bit values, so 65000 is close + /// to the saturation of the main sample.) + auto main_values = randomSampledValues(65000, 16, 1); + auto wide_values = randomSampledValues(num_wide, wide_skip_degree, 2); - TestSet set = deserialize( - craftState(skip_degree, std::vector(samples.begin(), samples.end()), /*use_legacy_format=*/ false), - /*use_legacy_format=*/ false); + TestSet set = deserialize(craftState(16, main_values, false, wide_skip_degree, wide_values), false); - /// Before the switch to 64-bit hashes the estimate overflowed to a value around 1.8 * 10^19 here. + /// Before the wide set was introduced, the estimate overflowed to values around 1.8 * 10^19 here. EXPECT_NEAR(static_cast(set.size()), static_cast(cardinality), 0.001 * cardinality); } @@ -207,10 +196,11 @@ TEST(UniquesHashSet, LegacyEstimateSaturatesInsteadOfOverflow) /// computed as round(2^32 * log(2^32 / 0)) = inf, and casting it to an integer /// was undefined behavior that produced values like 18446743978444128518. std::vector values(65536); - for (size_t i = 0; i < values.size(); ++i) + for (size_t i = 1; i < values.size(); ++i) values[i] = static_cast(i << 16); + values[0] = 0; - TestSet set = deserialize(craftState(16, values, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); + TestSet set = deserialize(craftState(16, values, /*use_legacy_format=*/ true), /*use_legacy_format=*/ true); /// The saturated estimate is 2^32 * ln(2^32), about 9.5 * 10^10. size_t estimate = set.size(); @@ -218,15 +208,80 @@ TEST(UniquesHashSet, LegacyEstimateSaturatesInsteadOfOverflow) EXPECT_LE(estimate, 100000000000ULL); } +TEST(UniquesHashSet, WideSetsMergeAndThinOut) +{ + constexpr UInt8 wide_skip_degree = 20; + auto main_values = randomSampledValues(60000, 16, 3); + + /// Each state alone is below the cap of the wide set; their union is above it, + /// so the merge has to thin the wide set out. + auto a_wide = randomSampledValues(12000, wide_skip_degree, 4); + auto b_wide = randomSampledValues(12000, wide_skip_degree, 5); + + TestSet a = deserialize(craftState(16, main_values, false, wide_skip_degree, a_wide), false); + TestSet b = deserialize(craftState(16, main_values, false, wide_skip_degree, b_wide), false); + + double a_estimate = static_cast(a.size()); + EXPECT_NEAR(a_estimate, 12000.0 * exp2(wide_skip_degree), 0.001 * a_estimate); + + a.merge(b); + + /// Random 64-bit values practically do not overlap, so the union holds about 24000 values. + double expected = 24000.0 * exp2(wide_skip_degree); + EXPECT_NEAR(static_cast(a.size()), expected, 0.03 * expected); + + /// The other merge direction gives the same estimate. + TestSet a2 = deserialize(craftState(16, main_values, false, wide_skip_degree, a_wide), false); + TestSet b2 = deserialize(craftState(16, main_values, false, wide_skip_degree, b_wide), false); + b2.merge(a2); + EXPECT_EQ(b2.size(), a.size()); +} + +TEST(UniquesHashSet, MergingLegacyStateKeepsTheWideSet) +{ + constexpr UInt8 wide_skip_degree = 20; + auto main_values = randomSampledValues(50000, 16, 6); + auto wide_values = randomSampledValues(10000, wide_skip_degree, 7); + + TestSet with_wide = deserialize(craftState(16, main_values, false, wide_skip_degree, wide_values), false); + TestSet legacy = deserialize(craftState(16, randomSampledValues(30000, 16, 8), true), true); + + /// A legacy state carries no wide set: its elements are known by 32 bits of the hash only, + /// so they cannot contribute to the wide set of the merged state, and the estimate + /// keeps only the 32-bit precision for them. + size_t wide_before = with_wide.wideSetSize(); + with_wide.merge(legacy); + EXPECT_EQ(with_wide.wideSetSize(), wide_before); +} + +TEST(UniquesHashSet, InsertPathFeedsTheWideSet) +{ + /// Find values whose 64-bit hashes pass the wide thinning. + std::vector passing; + for (UInt64 candidate = 0; passing.size() < 5; ++candidate) + if ((DefaultHash()(candidate) & ((1 << 18) - 1)) == 0) + passing.push_back(candidate); + + TestSet set; + for (UInt64 i = 0; i < 100; ++i) + set.insert(i); + for (UInt64 candidate : passing) + { + set.insert(candidate); + set.insert(candidate); /// The wide set deduplicates. + } + + EXPECT_GE(set.wideSetSize(), 5u); + + std::string serialized = serialize(set, false); + EXPECT_EQ(serialized[0], 1); + TestSet restored = deserialize(serialized, false); + EXPECT_EQ(restored.wideSetSize(), set.wideSetSize()); + EXPECT_EQ(restored.size(), set.size()); +} + TEST(UniquesHashSet, RejectsCorruptedStates) { - { - /// Too large skip degree. - std::string data = craftState(64, {}, /*use_legacy_format=*/ false); - ReadBufferFromString in(data); - TestSet set; - EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); - } { /// Unknown flags. WriteBufferFromOwnString out; @@ -236,4 +291,21 @@ TEST(UniquesHashSet, RejectsCorruptedStates) TestSet set; EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); } + { + /// Too large wide set skip degree. + std::string data = craftState(0, {}, false, 64, {1ULL << 40}); + ReadBufferFromString in(data); + TestSet set; + EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); + } + { + /// Too large wide set size. + std::vector too_many(20000, 0); + for (size_t i = 0; i < too_many.size(); ++i) + too_many[i] = (i + 1) << 20; + std::string data = craftState(0, {}, false, 20, too_many); + ReadBufferFromString in(data); + TestSet set; + EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); + } } diff --git a/tests/queries/0_stateless/00973_uniq_non_associativity.reference b/tests/queries/0_stateless/00973_uniq_non_associativity.reference index 9a120bc71911..9e0c113389ff 100644 --- a/tests/queries/0_stateless/00973_uniq_non_associativity.reference +++ b/tests/queries/0_stateless/00973_uniq_non_associativity.reference @@ -1,2 +1,2 @@ -80040 -80040 +80041 +80041 diff --git a/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference b/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference index 20d6a173fdce..1f916b310ecc 100644 --- a/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference +++ b/tests/queries/0_stateless/01088_array_slice_of_aggregate_functions.reference @@ -1 +1 @@ -['\0\0\0\0\0\0\0\0\0\0'] +['\0\0\0\0\0\0\0\0\0\0\0\0\0'] diff --git a/tests/queries/0_stateless/01890_state_of_state.reference b/tests/queries/0_stateless/01890_state_of_state.reference index 9e12fb2f5c3c..a863391647c9 100644 --- a/tests/queries/0_stateless/01890_state_of_state.reference +++ b/tests/queries/0_stateless/01890_state_of_state.reference @@ -1,6 +1,6 @@ 1 1 -019FAED84DEB6883BE8750E4429D9305C0 +01180DCCF0E8E7B543FFE20512CEAC9BAA 01C0728DA5A9FCEE86FB6B6A6957296F70 AggregateFunction(uniqExact, AggregateFunction(quantile, UInt64)) UInt64 @@ -9,13 +9,13 @@ AggregateFunction(uniqExact, AggregateFunction(quantile, UInt8)) 002000000000000001000000000000001C3633363431333632323338343637393330303520302031323334353900 AggregateFunction(any, AggregateFunction(quantile, UInt8)) AggregateFunction(any, AggregateFunction(1, uniq, UInt8)) -000001C2FFBEF723D70094 -000001FBFD3A96D95571C4 -010B0000010000000000000000 -010B0000010000000000000000 -01C2FFBEF723D70094F90D214AC9BD1133 +00000197BFCF7B +000001FBFD3A96 +01110100010000000012010000000000000000 +01110100010000000012010000000000000000 +0197BFCF7BC03F452A390FD22A1014DC7A 1 Row 1: ────── -hex(toString(anyState(x))): 01C43E0000E80700000000000000003388DB1D6772005049A73DC93E5F02C81E0744088A200314F27E1B5971930364D869BECF448F031CDDD168E4CB29077411F5BA7C00F407804BB94A3F3CD3072C939B0D1D3EB909881BEFFD2451F50B484D762EDB911F0C04E5010A912E450DE41B4B4A3AB035109893DF4178AA3C1054F3282BFBF14212A07B5D6581ACC013007D9FEE1E0F8915CCA0B5EBFB1F7718204F2A35AD8EF3181C07DE613913491944F14F8B54993D1A2072A8D4244BF31BD0C03DB2FF5D7E1CE030658D8BD9B21D144791B8E9C9F01F5075B76266DD8D1F3CC48B0E8F286021F4D1A7BE9744CB217C68DAA3E018A22230E7515676E3852440500BD59FFE5E25283B03FD36BB272950CA6B2E0B4EA729B0FEF3A84F9F362A7894927596A3F32A646361A6BFFEC72934A621D4411DC729A4C5EED6E842B62B087E299C3163C22CCC59154FB6E6E7297C82EFDFD9DDE52D847AA5EE3AFE5A2E08B15DE5110D9C2ED013FA43E8B22A2FA0295EB9A235C32E7C5D560C305704324CA3C48C79FD5C359472143538356F3608954262FF95CA36203AEB36210B023730E550A9C996E637A08CF4BEEED85438DC847CF58217CC383CD0C5364C006339C05EE651503651364C81030873356C3BBC61CA1A17B3653B2856DE4D6457073CA89B21D104A35D3DA0373EC903EADD3DBC6F9131BE6D393EAC15D69A04C5A43E20270839DE6FE23F9C8F2271D393E13FA09AF855B41707417CCE2EFF95ED3842F0D8F5872027CA433C20CBAF80A1F0434825A131DFE53D46708F06756079724AFC7FC1A0FB06A74A2451CF11916C144B20A016AA3FFDBD4A505302A6FA1ABF4AD48D312115BAA04D4469E536D347494E5CD31E7DA2BCF14EFC85B244A4CDBE50643414666367E452F4E211B93B40AD52E85C227129871E54B8FDE7519F3E6D54F0765C53B0608355347B7179511B9F553C2CCBC234FCBC56B4F52C0233138E56F42925EB2C47E755C837FA4CC4402A5808C1F462B0193356B405D2D843BA8F57445B4238E41C9E59504F917D29171A5AD0E17F2828CBBD5D40D732E5E11EB75EAC2FA504DE0C435F6077A91FE550DF5F68184FCE3B2D445FD058667AC7C7BD60340DD3482544726164837D791E1E0C60E074B649753CED62ACA7A1BABA2ABC620CF32A2385376564F00534FCA8CEC16480BBAC1860AE0864985CCB79565DA765F8F998DE0636336628A548497A052E660C4C33C606183567AC30433B08A0D368C0F7C45D0A8EC96980CD7EE97A6FD96C04A2E2DCA675776DBC2B85C4FE2B956D408D07821AE074713061AA6A287690717CE24439A0BB4B7270970423EA85D872BC014CCF3597C3722CF61CBCD263A373B8543943E50CB075E4642F36A1F603764C41256A0D114078ACDC6026F74C2D790C6E7C9287D4BB7B785B65EC17E3D47D783F69ECBEBB037E589156377B1FBF7EE488F0B15CCA297E10D033A24E51227FB417D1EFFAC80A808852871FB946E180E85ED7F8B0936281681394D9D15CC582246ED6BCAAE94C839048766CEBB408855C41CFC51D7543875C4991F3925E90879899BEE72F691788D85A7664CF924D8B28DFA886710EBB8B00E3508739E1B88C903F91E258207B8DE44BA164B649A08DC8BD5FCCFB2FF18E84223C7E7C7C018F8C3D2B04AFBBA38E145242FC586C379024EAF5D843EBD590CCA775B10D7534904474057F658254924C33800B96E78F939CEB97ED5EC53F94D457D517D3C50B961C51C3DA7F6D0C96609C263FDAFE539794A596BB013EA697345F44ED62F2A397945C20A19B5502998486CCDFB0307D9FC465D026315105A01CB376841B8655A0643659CB98AB05A2B8F185EFFB6819A3DC349F7344D8D4A3B8B29F5A129735A5D81A4D2186F4DDA760BB3AC0088D09A8649DCB2CE99906A944EAB6B4E525B0A9E07AF332DD895DAB5C46E9405E7046ACDCA43C0ACAC223AD0488B2E1199E1FAEA8AF87E8923321B0B0073F66EE89DDB0EC63B4048C178AB10C2349DFE2708AB168D195FC62B2E4B154D7AE66DBB96EB31C64E59B08F834B31CCADD8E335F43B46073457CC7B1B3B4E8FC87793C7295B68CAA9E31FB7B6CB7EC773EB53CCB00B80094F83D69444FB924BD42C3ADFD81B9F00986DCB86052B9C0F13E32CBD16BBAE0C3FF5050967BBB8006671F81757ABC343B18DBFFC224BC0054D7A1751573BDA08E5054DD359BBDEC0A5131E6EBA7BDF85E4A09BC1986BEC0DBEBD1326DDFBDDC5BE3FC0AA6CFBFF4624B73C181A6C034B1CCF48E8F7BC1680745A5EFD895C31803D817E32941C51C008E6943D307C6DCFCD5A43A6EC2C6BC3F0C321ADD12C67C0B6F1BE3C0BFC6ACB659C4DFD49CC70C4D8FF695171AC954E3FBA05EB67DCA1C4AEBD65CDE68CAACD676166177CECBF0C0A6EC5FA229CCF8F1D4F2641EC2CC004B9192F40A5CCC18734EE20E637FCC6019723B849E55CCF04AC9BD54A4F8CBE8EC5EA8364AFFCFE82EA6A62AA282D0606916DF2DAE03D1309F742741F632D2C805162D1F9423D4800EF63A27CB48D5F897AAD1B6E0E0D58C9646F6DF92BED6E0D72EA8BB7055D7880908049FEC0DD87870E9D2A45C7AD8C4EA3C8480BCBADD9851A086BC9A49DF3C5F5A80CA6985DF3C5BC218E0415BE0904C21189E394EE0687CD853CC7188E0D02A4FD353C64ADF64566CE9A67A2CE2D4B92E1B7F5F8CE220A98484CDEEEBE2F0BE86E6786FFFE3B4B26A11FAAE15E47894C7BEDBE846E614397EADC06DBEE68000FA83B45307E8F0790CA5A3702AE930DAD55C53CD14E9806B0EB046582EEB74312828AD516CEB6C6927550AD6E6ECBCA42E80883FFCED48E4FAA9C64577F0601D7047F35790F1F887DF8DC2C65CF2980B26509561A3F264CD4F7D3D48A1F2F03D323C0D6FB1F300D16E141EB57DF45085C80FA0DDA2F4CC35B0A844382BF5880D542B62F3FEF59498CCCD4A153DF60C4224AE3F3001F50819AA70A24C9EF7F41563FB718751F86007BF338286FEF83840476F7F3CE4F97844B51C899015FB94CD56A86B468DFEB80F30417DAC8FFF28DD6A05ABAF48007DAC4A1A3F33E7FFF40C1493658345010166747E0EEAA101199EDAA9C412240031551413A13035030533054A8720080631DDAC79D5D39906216E9296AE2533072173297E7BB9B60785CC59428E607908815A100E5E4BD2094590ECD8D669110AB9ED2C2BBB4CDD0CA578A19461CFBB0D29736D932B87890E554592294BF4BA0E8905D3D9286C0612B97A596B33875012BDFB3AB7BE738312510D01F55DE3E71321CC6958542F4A14A5C12DB3D5F6E715DDBE510DE31C761701E4127C9D77AB17D549F05F9A808C179D16D077EE77E2187D4E66C9E0F5F71899F1BF5692ED331AB1BC74ADC6B0C21A7D728EF30526631CD1DD0C272904F81D61D5F1F4F71F9A20916329814441BF20F1FD2CFDD188B62315DC535E434F60244D393294CBBAF726A53E7722F7714927CD92DEEBDD72A1277DD92431E62EA728458E62B3B5FD252DF913A8DB0268012E755149927EA1D12E1D8DD629B5ED122FC5F97CF2E5C7F12FCDAE7EE48166D230397AC33C3C160433198FFE8905FD0A34B9629D4B39CAFC34119E76B7A7E4B537EDDF846CA8D4EB38BD174A7CF818773951337DE1668F913AD5509E2A024E173BF517F87FF868983BD5C4E5A8C48FFD3B85DB9F7E9E14BE3C258694460322683DED90D2914C7CE53D09E86CD01B402D3EB97BC13BDA84C13EF56B479722F7C63C896708F84A2D52403D1E93FD13B5C740850C6B7A50FE7F414591B0DDA01A9D4015EBDCD521631B424DAB809A300ADB42A976D7075B06DD4121569700EDAAB94469E20BDF77D13246B92E4BA7869F6F47216ABACA596D4649CDA42C5EB3D17D49717D79C05CA8CE4A314CBFA2B4B22C4BC9B5D8DD0C71FE4AA1EDF3A6DD70124D2D9FBC4D9ACDB94D1976DF64EA147C4E894C3AFDE8508A4D911152E32D6F3151392B496741A572525D3671DBFF29DE520D9914C708153E53511F1A1C942C5255218C326CA780E655F97C6DD8C56F4356C5F662AB30B82E574DA7C71069444659593EB4FCECFD2A5A59F4189D591BAB5B859B36D8787CD25B998F8742CA1EBE5E21A0FE761D6FB160512A75FCA112566105431335728A61633D26D46C8A0086639503663D93016564C94A065C1ED7E26405C1A49709F09E63BD50ECC22EE43B66E591E56787B0E56651F7F5EA6DD15D6C7D7A6368331AC46C69F80FF3F8E42A6D197B2E7D64A21C6C5DFA7C24E3EB056FF57AF9E9229D797049ABEED7CC617D70E9873A91F8441A76C9F2476C04B159778D8499C06CB4EB77C1888E4F5BFF3D78D1B89901B4979378094C688B993CA67AAD4108FB73A6447B290D4381E00BA47B49CC626C9C8EF57C497B5ED9CEBC337D49E69A9C8B0E5C7F71F815572F37498129C596E682034D81695293796EA8FD82817A2E01B6E34783F9E00360AAE59885314A2993806A6B864931921DD1377F87611A0030751D6F886DDBD0D6C4CD3E89910633AD9A8EB8893DBE6CBC8EA5F88A55BA5F7B17CEF58A0DBD88D1B55A6F90513520EDEF995891757604E07B4DF091812DE2CE18BBEE92D975B4F5A0F7A79239C080587B7C1193399EF257FD533294DDEDD623D23F6594DD7BB2F61B457D95AD31F166AF3FF9952540F045AC5B2096A192FF528A346396ED038319EB9F4595AD804DD93F445398F16B1AD135B8089AB5077C418803849A8194D973AB65FC9C553BF027CF5C6F9D35DA059B5881029FA5A0BE4D4B48EF9FE53E0A89CD1A4DA2C5C629A27D5783A29D0A580BF25EFAA281958B33047262A4F93CF0624330E6A4A9F263190A154AA611BE9DEE84FF47A8F97EE94A8D5C99A919D8D093615792AE99D5512F75A04BAF1D31EB61ED07F1AF85070DC926576FB0D55B6558698B3BB1B90268E08FE573B229FBB6BB195910B2B9512DDEBECD95B5C932BA50366E20B62955380593E651B7C9D47908817975BB313B899B8CF3C1BBD57FD448595B2ABC695EEB2F291BE9BC353191F19D4849BD7D552821F6753EBD49E207059F7797BDB13717EB8FB33FC04D0C535B929714C1DD04E3BBAF8EE6C1ED784EDB7B6B29C17D5DE836754C82C21D44F98B9144B2C279656DCD138A22C4A5AF5AA08B9CC4C4A9125A8E663D81C46DB849A762EB3DC575BE483852C206C71186400F76F0AFC775C1F9447C669EC75D9138A03863B2C899EFA416D3E817C8D54EC0770735DEC7C5682CBD5B0813C769B1BDA83FC423CD6D3EFDFEC74CCCCD29209B0E6111E8CDCDC6D152F31ACBCF09FBE33BBEF37AD06D163213F33003D10DF2184A47E154D261E942BEAE90CCD555FF2584ABC5F4D651907A743D174DD77594DB4EBAA25DD8F9C796F5DD4A54D9D1EAB31DF52A85D98DE9D6E12FD253DA91F0BA0A9FC9CADABD3562CAC52E04DB95B99AE048EF65DC89DBD64DE0D28FDCE59223F1514C7DDE11326FFE7D64D3DF59B246DFF6CDF5E0415D2418EAA8BDE035C18AFB1B7E1EE209735292BB9BC1E239D63744A281EAE35D174CA02AC346E5C9BFF44AFE62B3E66503E5109F540EE7B94A13C0E92599E7459D65E9638E4AE93523E5FEE9D6A5E9798AB49CB63971EAE1DD5771A371E3EAB5AE9D3A293EB0E98D9493A1EA16AEEB017196B68DA052EAADD0DEAA685012F0F50E7E67040DFDF171C274AAC4231EF46182F66118D410F5E168AA9C55AB36F6599C3F40593AE9F69DA93B2B2D3536F9052088036888CCF9313A676230ED6DFAA9CB024121D0D1FB95B70606915525FD71C73796D75E25FE717240AACF7E1DFE8501FB630EA753FF8D184303CF9B82FF0DDD5928246A1D00D2288B241DA6A200126B7061A9A9BE00CEDBD7614DCBB402DECCE8FC1CD44303329EA6047285F00246BB5A2FF1134A05C26CE498758ECC061AC5D069096564087E84E9F5F5A1D00866C9EF203C551E082AD9231BE1D9BA08FA873B4B6ADB4E0ACACEE0EEC929430B9627959A56C7790C0EB351BCB62D3B0CBEECCCC04096780CB6D6657807ED2010A2E245874714B0107A6430BC1D8B54122A38B925E0D1B512022B1128310B62146EB130D35FFFE3141AD4E6C151AE6316368EDDEBBA180B1882C05A02F10EFA185ADC474AEAC245199E1F2E091BB1CE180AB05AA6725F9F1D261C774C0ABF0320BAE92996D8D465225AD6027F42E68322CAEA4261B53B30241E35E6C0B8CDA324B619995022AE2825CEC4819F9F98BC2622A0C08780117D27BAF32CB4945B8B26DE1B02EABBC6CF27423F65F8F4577D286631F208F7ED2329CE0414AC84CE0D2BB2A9A8D14A74C92B6E0CDEA198D2203056BB9AB101AE8C30FA9250AD10BDA93352247E9CEF34203462E22D37388D5E35C6782B7532FF02363AC7E1DFF02AEC362AF9CA45E28AF1372A19A545B1679C37EE305535143BC8383EE41CE78B4CC638A2F6C6FDB09385397EA05C046678403B9ABA194E5208F03BC2A902C2E45AAE3BAE880CA49E58493D0AFBF4447AF5923D9EE81951E728913FDA58B0D0244677416608F8CF32C89341D6D0743F38EDAD446E089FB970234645CADE2FE6BD977847C2BF772B46E9AE472EC580B37A840347C6ADE164946AC9479EBBF64114D5094AB652CBDD001FD34B1A975F8169D9154C467D65EC37D6824B72271AFF76B7694CDE7264281775EF4D4A9B9260DA8F674EFAE261F152E6EE4C1E24BDD7BBE5424FFA8632C0551D8D4BE6B24962CC5D4E518A0F59A27B94CC546E8D7B59DE58FE54BABD7999AEC4AE55AEB5D38EACD88D544AC673DB46DF3C57421061C1E53F0A592E0DF4C865B6D85B422D06241284015C6E1BAD53EADB255E8A1982CA5052575F22F3F9E44B8FE35F9A654F45B2AFA25F229BF088F25D2062229AA335C62EB3636E9951D8F82440653AA82D22F69E1966EAB678548B8D15674EC66D8A6DD918683ED2DB58EAEAF66806E606D31B2674688275554CD537D76AD69DA4D44951076BAA86E6AF7CEE346B6A26A3CC6518356D2ABCEC45C70BB06D6241AACE3F665C6E069B6E311D26A66E2EF064141342BF6F86627BCD965AC17026BD2621457BB171160344C518438B71EACA63DF6D7423730A45073B5D38B373B2B5694074A88C73769EEDE7EC810073CAD9367CFA7B2976BE3E3BA51FA7EE769A298C832DEBF07832C62792BCB07D7A0E06AA15F179FF7ACA868D9C48C82F7AFA33ECBEB953007CEEA25A8E04C88A7DCA7825DBFDECB07D867CA7F606F4A27E028E5FD2DFCE6880C2E766526C214D820AA793B8BBD93E83CA586B9EA8C65D89BE1DDBD9B4C9AA8B92E616E72AF0D18B2617469A27D1F28C0E5EBA562358618DBE4B5D5C5BD02291DEC91EAC8B12B292CE2B97BE8FBB6A93325E3D39CA56BE93CA9F3B91BBB8A493E6D5749533DA8C929AC50F022CE9A6937E3B792980D4DE94529348CB1D484E9626648B8572CFC395CA30C9F2ECAB359756BB0E02F4223E93C29724415CBAD0925EF73B2E71B8D7947EE26382053DB199C644A4591013ED9CDA99BE70B3C7489D6A5C3D0DAE6CCC9DDE9682E4A8219BA09692C036314EB4A0EE4470472E46CDA1B2D3529EC553C0A1EEEF182E52CBA3A3D2BE8D96050820A5CA3E34382859A4AA4287C6A393A5CCAB46BDC85E7BCD1DAC7E3DD7C3259CF1AC5A37D27FB7E34FADAA1E48523F375DAE8EDA0C489E41B5AFDE8283ADE99BA0AF6213BED6BE72F9AFCA063E3E4BF335B0C6A697364AE8C5B56E09CF4A56DC1CB6C6E9313A333656B70ADD5639B468B5B766ED20487A5368B99EBA7246A755EDB90EF9233682D8ACBB46B981EE655D6ABC9AA348491BAFB2BC8A26B4C50C1E53BDD6EFE08F005500BC42622C4AA5942CBEE6877F7919D8EEBE4AE7830665202ABF3AEB1D9D5B2A87BE8EA9BFC22D6908C062A2ECF31CBC8CC076E5C02A6ED8F2C16699528EC98B97C2FE518FF9F35D10C48AF0D6452456E4C5729F2192944A88C5EEA623FDB32FB3C9AE9878B3979FFCCA92258E4AB8A926CE72A509D46A28DACEDAB019918A067BCFCE5AA706C643B9CFC61554E70A067BD08654443AD08A3BD13E12EAF4DD8CA7D186EC5AE181B0ADD37E8FC70E57FCDBD3C6E68A1C84AF94D49A7D3E790FB46BD45ECC55936AE3F0D516F88E08A15C75D9263F4195DFD453DCAA3E229976A4DBDEAA092D47B39E40E236B2B6E6494511E2D2A7E9D5436857E352E07C227E33CFE3AE6C7DD57FE884E3021BF0EA34955EE24E1BB06E47DE2EE38EBBAFECC5F45CE3AA9F899F9B2C88E446795288316C7FE7D26A3431D45D89E85A0456D5036001EC6E0AD12CA9139FEC4A190A42B84E73F0CE5BD8FF700BA1F11E0CD9852D6C04F3A28DD2F427095FF62A676B0D7245A8F6669396AC6556E0F64AD3CAE6570197F71A7A03AC716D1AF8C2DB9D992456ECF906A901647AEC21FB561AB150BBFFD2FDFE9643BD3C768DFF0655A25700EB2001178FCEFB70C2FD01775061317B7C6303A7FE02C0521BE40547E12B319D1A90068FEEB4C9B123DC07874FFDEC947FE907ABED11F375C15F0B73B2DF1DC53B430C53D865AA819EB70D8358A39EC10FF60ED7E35CFD0F7358105399C520C1803810A7B6A378AE71CA10B374A82BA05503126BB1E4E40CE7C013EFC0BD81B5816E142B00F178980314142B275AE328240E15936862290E2B77167B37E77E0DD840199753518009736319878E29B34F18831AE7097125E93B5F1B3336EC06C0326D1E437A11A36BB5DE20A3B8CBAC25399A21FB68BA1FDCF65622B79ED91FA196DE235BD670320AB5E423CF36FBF31F699B26EB92E8418A72C9264755758BD770CB268329890FDD3A1A2937557784100D212A1BC6638ADCCEBC2AAF4C9C5FB936F72BA70A22A22773942B3774257985435F2D870364D3D487B32DAB76BF78B74B182D9BEF2C34CB0FF42E37D44AC91D66472F7BD05D746FB44630DB51B1CD53137330B75B3CAAC5C68A3327E336C5F66C0C349F200E4A002AC234F78FB73DDDE6E9347788C9F69C8F98370BEBE71146C73D380770D581715FA037C7EBA5C175B27C394B5BA3EE6D8C0C3BF74BFA779F34C63B9B8C6432066B8C3B577A95D3B5B5CE3C2B1976DFDC29003E77DEFEC30D299C3E0B9AED19B8B74C3E1F82B4FA146DD43F1F3F323AE6AC3B4083407D9B1690DE3FCB9609C6CFA3FC403B73E5DC7D8215425F738B7355F8E84593947699F9B06546CF964B51517CE546F3FD9C2CC4D99549F3CC57456C556E4C83B94A77A4140D4C8F8359065BEA244E6FD798F750D8624E03CED8F809C581510B8D3E251AEBE8530FA6FE57E912705417A67910027FBB554399FB060BDFE056E3234530A85467588BE1F64EDEFB4C594FA74D8F2F669C59ABD34B1B25F4E25A37FB6044A143905AD7761024BD29B45C4FB1A12743AE245D33A6705B78382D5FD39DE58D1351245FD3EC881E4242C3619B5CA52FDFA4D262DB5CD53A8935876207CF10494A25C462772700E0882A67635BE350D85D89A664777A42F48F72106593BD234B091B01671FE72112570203677F26C1375065D868834489C6D37249695F68235AFCC33269D34F14ABCF10CF6AA360938C1E962B6B0F0BBD8A46ADF76BDBD6057DA2D2036BA37C478450AEBA6C1302717701585E6D03DC7A9988B8816C87461AC7C123E76B23C541F3E67FE46F03BF824C92C66671BFC2FC2D681AB2723F28D559EC7968734B700BC9BD4DAC73835B355E10DA77753B5C1FA31910BD751B09317449C34776931EF7A0360A8575CBD19B172BE0E97D5F9947AC6B23767EC7A8B0987DBEB181D3C5A58CA33248830F2BFCE82FE341846FC8AA061C23B484B3BAD06DEA9804853BF3DAE01346D485D3A0AC7A4F016084E7E684B2F8F65E86F7FAEC8D42170C877FB213B4A37F0E869B8F01E90C5D2789BFB19D7B3689FB8FCF7518F0A8680490477A7B0925FC2C90E3237140F1C6649133EF36DBB5DC1C9067AC9E2B44824092FB98326BF511C192DB023C6BA5CF5C935741C6D871C22D936F1DEDFA032322927FECF1A5C1A2F1947B2A2C3347A1AF944F60F88AEBFFC595EF2C2BD2E581219757BB0E2D6FE3FB979F29F095BE100798B36B8528042E349873D15E70FEF15C99EB4E97FE4602269AD33557AC0C21129A2FCBBE83CF7E039C770EF4AF5E9F8C9CB37E750A8787399DABBCC4BE707B329C0B56D46468C48DA0BF2A47A696BC0EA343B39C73D2A380A73F53F627F3EF69A8BBE4BE37D96FBDA7D370B7C50B8FE2ABF344D5B03AAF05ACFB707B27EF7DA6AC277D30A2D02148AD6B6ECE7A13F989AD4BCE189FC48343AEDB9B8A7B1AD5C5ACAFE7AF55DF3204AF839879F785B943ACE7365CA3447AD5AFC3CC4AC206B871B31BDE91A5C48D80B38F5A6F10EFEA87B4934565856FA218B6D3BECE95ABF3D5B64FE604FC0999F1B6FB4B6724C34719B8B77335C111DECBB813F475643F1E34BAFB3656CDCF1488BABBFCDF1EFA855DBA7FFD05548D48CBBBBF0DCC7E0426C2BC131DFFAE25ADD7BCD31B462697F1AABDEF45313DD8270CBEE707E056242CD8BE13B84AD4B764DFBE93AF7DDE172DE1BFD7502BAFE8FDCEBFC337C5E34EDFE8C087FB01139A6702C14FE252C65119A4C1073BDE03DC8DBCC377F552572E26F6C597C472D5A181BEC6EF95563F0C9BBEC7AFCC7AEC1FDB23C83BE44ECEA71BA5C8572063EC78E196C9B7159699A3D057CA27A8F661C99049CB7F9615E9B2C090CBAB4CD4221EC681CB6B187E4FA4B4ACCEB79C3F95F16F67CF27D7093F0F4FE8CFBF2B6A3234E246D05F1A31CA13B119D0772FE95D9A6C4CD117FBB941E9244CD12BE03923B1430FD2FBF1881FE9CB5BCF7F8F703B61C247D0635873AA17FCA4D34367B086764C24D4F3156939ED73E2D4C31CFFBE92AA66D2F3CCBCFB82DC21D6F327DFE3B34422D82B5FFF9EF1CE0CDB0B1006608827A5DCCFB517FBBFD1FADE6B9B8E4E2BDABBDF13DF6F28857840E0EB389F49953BBDE03F9DDF7584F60DE103A0BD5551A024E0EBAAACE1CEE3C2E287272FFFDFBD56E3934AAB1F864DDFE35740A69AB4E3A8E3ABB69265D1A857E4EF1E35FE76FA45E7E3ECE0EB8D8F8FE7BF8D18E5C9D80CE8BBEB849FC727F4E7DF60BD6743F873E75B51834C442880EB5F5F77A1710D78ED1F508CBABCFF90F1FBE6D7ABEF82D8F29B4C7F17204F17F35B401007D01099F363510B9885180BF4EBD3A8310E9D73F573EA797F5D48F9F527EAFC496ECF7DF6DF960582C2A0A3F72B41DC02BB88B4F91FF5BCBF6EA4FCFA93BE83BF8CEAA6FB0B20E6DE183804FC7B59D5F023324AFC5B058AAC5DC4B1FE03 -hex(toString(any(x))): 0000E80700000000000000003388DB1D6772005049A73DC93E5F02C81E0744088A200314F27E1B5971930364D869BECF448F031CDDD168E4CB29077411F5BA7C00F407804BB94A3F3CD3072C939B0D1D3EB909881BEFFD2451F50B484D762EDB911F0C04E5010A912E450DE41B4B4A3AB035109893DF4178AA3C1054F3282BFBF14212A07B5D6581ACC013007D9FEE1E0F8915CCA0B5EBFB1F7718204F2A35AD8EF3181C07DE613913491944F14F8B54993D1A2072A8D4244BF31BD0C03DB2FF5D7E1CE030658D8BD9B21D144791B8E9C9F01F5075B76266DD8D1F3CC48B0E8F286021F4D1A7BE9744CB217C68DAA3E018A22230E7515676E3852440500BD59FFE5E25283B03FD36BB272950CA6B2E0B4EA729B0FEF3A84F9F362A7894927596A3F32A646361A6BFFEC72934A621D4411DC729A4C5EED6E842B62B087E299C3163C22CCC59154FB6E6E7297C82EFDFD9DDE52D847AA5EE3AFE5A2E08B15DE5110D9C2ED013FA43E8B22A2FA0295EB9A235C32E7C5D560C305704324CA3C48C79FD5C359472143538356F3608954262FF95CA36203AEB36210B023730E550A9C996E637A08CF4BEEED85438DC847CF58217CC383CD0C5364C006339C05EE651503651364C81030873356C3BBC61CA1A17B3653B2856DE4D6457073CA89B21D104A35D3DA0373EC903EADD3DBC6F9131BE6D393EAC15D69A04C5A43E20270839DE6FE23F9C8F2271D393E13FA09AF855B41707417CCE2EFF95ED3842F0D8F5872027CA433C20CBAF80A1F0434825A131DFE53D46708F06756079724AFC7FC1A0FB06A74A2451CF11916C144B20A016AA3FFDBD4A505302A6FA1ABF4AD48D312115BAA04D4469E536D347494E5CD31E7DA2BCF14EFC85B244A4CDBE50643414666367E452F4E211B93B40AD52E85C227129871E54B8FDE7519F3E6D54F0765C53B0608355347B7179511B9F553C2CCBC234FCBC56B4F52C0233138E56F42925EB2C47E755C837FA4CC4402A5808C1F462B0193356B405D2D843BA8F57445B4238E41C9E59504F917D29171A5AD0E17F2828CBBD5D40D732E5E11EB75EAC2FA504DE0C435F6077A91FE550DF5F68184FCE3B2D445FD058667AC7C7BD60340DD3482544726164837D791E1E0C60E074B649753CED62ACA7A1BABA2ABC620CF32A2385376564F00534FCA8CEC16480BBAC1860AE0864985CCB79565DA765F8F998DE0636336628A548497A052E660C4C33C606183567AC30433B08A0D368C0F7C45D0A8EC96980CD7EE97A6FD96C04A2E2DCA675776DBC2B85C4FE2B956D408D07821AE074713061AA6A287690717CE24439A0BB4B7270970423EA85D872BC014CCF3597C3722CF61CBCD263A373B8543943E50CB075E4642F36A1F603764C41256A0D114078ACDC6026F74C2D790C6E7C9287D4BB7B785B65EC17E3D47D783F69ECBEBB037E589156377B1FBF7EE488F0B15CCA297E10D033A24E51227FB417D1EFFAC80A808852871FB946E180E85ED7F8B0936281681394D9D15CC582246ED6BCAAE94C839048766CEBB408855C41CFC51D7543875C4991F3925E90879899BEE72F691788D85A7664CF924D8B28DFA886710EBB8B00E3508739E1B88C903F91E258207B8DE44BA164B649A08DC8BD5FCCFB2FF18E84223C7E7C7C018F8C3D2B04AFBBA38E145242FC586C379024EAF5D843EBD590CCA775B10D7534904474057F658254924C33800B96E78F939CEB97ED5EC53F94D457D517D3C50B961C51C3DA7F6D0C96609C263FDAFE539794A596BB013EA697345F44ED62F2A397945C20A19B5502998486CCDFB0307D9FC465D026315105A01CB376841B8655A0643659CB98AB05A2B8F185EFFB6819A3DC349F7344D8D4A3B8B29F5A129735A5D81A4D2186F4DDA760BB3AC0088D09A8649DCB2CE99906A944EAB6B4E525B0A9E07AF332DD895DAB5C46E9405E7046ACDCA43C0ACAC223AD0488B2E1199E1FAEA8AF87E8923321B0B0073F66EE89DDB0EC63B4048C178AB10C2349DFE2708AB168D195FC62B2E4B154D7AE66DBB96EB31C64E59B08F834B31CCADD8E335F43B46073457CC7B1B3B4E8FC87793C7295B68CAA9E31FB7B6CB7EC773EB53CCB00B80094F83D69444FB924BD42C3ADFD81B9F00986DCB86052B9C0F13E32CBD16BBAE0C3FF5050967BBB8006671F81757ABC343B18DBFFC224BC0054D7A1751573BDA08E5054DD359BBDEC0A5131E6EBA7BDF85E4A09BC1986BEC0DBEBD1326DDFBDDC5BE3FC0AA6CFBFF4624B73C181A6C034B1CCF48E8F7BC1680745A5EFD895C31803D817E32941C51C008E6943D307C6DCFCD5A43A6EC2C6BC3F0C321ADD12C67C0B6F1BE3C0BFC6ACB659C4DFD49CC70C4D8FF695171AC954E3FBA05EB67DCA1C4AEBD65CDE68CAACD676166177CECBF0C0A6EC5FA229CCF8F1D4F2641EC2CC004B9192F40A5CCC18734EE20E637FCC6019723B849E55CCF04AC9BD54A4F8CBE8EC5EA8364AFFCFE82EA6A62AA282D0606916DF2DAE03D1309F742741F632D2C805162D1F9423D4800EF63A27CB48D5F897AAD1B6E0E0D58C9646F6DF92BED6E0D72EA8BB7055D7880908049FEC0DD87870E9D2A45C7AD8C4EA3C8480BCBADD9851A086BC9A49DF3C5F5A80CA6985DF3C5BC218E0415BE0904C21189E394EE0687CD853CC7188E0D02A4FD353C64ADF64566CE9A67A2CE2D4B92E1B7F5F8CE220A98484CDEEEBE2F0BE86E6786FFFE3B4B26A11FAAE15E47894C7BEDBE846E614397EADC06DBEE68000FA83B45307E8F0790CA5A3702AE930DAD55C53CD14E9806B0EB046582EEB74312828AD516CEB6C6927550AD6E6ECBCA42E80883FFCED48E4FAA9C64577F0601D7047F35790F1F887DF8DC2C65CF2980B26509561A3F264CD4F7D3D48A1F2F03D323C0D6FB1F300D16E141EB57DF45085C80FA0DDA2F4CC35B0A844382BF5880D542B62F3FEF59498CCCD4A153DF60C4224AE3F3001F50819AA70A24C9EF7F41563FB718751F86007BF338286FEF83840476F7F3CE4F97844B51C899015FB94CD56A86B468DFEB80F30417DAC8FFF28DD6A05ABAF48007DAC4A1A3F33E7FFF40C1493658345010166747E0EEAA101199EDAA9C412240031551413A13035030533054A8720080631DDAC79D5D39906216E9296AE2533072173297E7BB9B60785CC59428E607908815A100E5E4BD2094590ECD8D669110AB9ED2C2BBB4CDD0CA578A19461CFBB0D29736D932B87890E554592294BF4BA0E8905D3D9286C0612B97A596B33875012BDFB3AB7BE738312510D01F55DE3E71321CC6958542F4A14A5C12DB3D5F6E715DDBE510DE31C761701E4127C9D77AB17D549F05F9A808C179D16D077EE77E2187D4E66C9E0F5F71899F1BF5692ED331AB1BC74ADC6B0C21A7D728EF30526631CD1DD0C272904F81D61D5F1F4F71F9A20916329814441BF20F1FD2CFDD188B62315DC535E434F60244D393294CBBAF726A53E7722F7714927CD92DEEBDD72A1277DD92431E62EA728458E62B3B5FD252DF913A8DB0268012E755149927EA1D12E1D8DD629B5ED122FC5F97CF2E5C7F12FCDAE7EE48166D230397AC33C3C160433198FFE8905FD0A34B9629D4B39CAFC34119E76B7A7E4B537EDDF846CA8D4EB38BD174A7CF818773951337DE1668F913AD5509E2A024E173BF517F87FF868983BD5C4E5A8C48FFD3B85DB9F7E9E14BE3C258694460322683DED90D2914C7CE53D09E86CD01B402D3EB97BC13BDA84C13EF56B479722F7C63C896708F84A2D52403D1E93FD13B5C740850C6B7A50FE7F414591B0DDA01A9D4015EBDCD521631B424DAB809A300ADB42A976D7075B06DD4121569700EDAAB94469E20BDF77D13246B92E4BA7869F6F47216ABACA596D4649CDA42C5EB3D17D49717D79C05CA8CE4A314CBFA2B4B22C4BC9B5D8DD0C71FE4AA1EDF3A6DD70124D2D9FBC4D9ACDB94D1976DF64EA147C4E894C3AFDE8508A4D911152E32D6F3151392B496741A572525D3671DBFF29DE520D9914C708153E53511F1A1C942C5255218C326CA780E655F97C6DD8C56F4356C5F662AB30B82E574DA7C71069444659593EB4FCECFD2A5A59F4189D591BAB5B859B36D8787CD25B998F8742CA1EBE5E21A0FE761D6FB160512A75FCA112566105431335728A61633D26D46C8A0086639503663D93016564C94A065C1ED7E26405C1A49709F09E63BD50ECC22EE43B66E591E56787B0E56651F7F5EA6DD15D6C7D7A6368331AC46C69F80FF3F8E42A6D197B2E7D64A21C6C5DFA7C24E3EB056FF57AF9E9229D797049ABEED7CC617D70E9873A91F8441A76C9F2476C04B159778D8499C06CB4EB77C1888E4F5BFF3D78D1B89901B4979378094C688B993CA67AAD4108FB73A6447B290D4381E00BA47B49CC626C9C8EF57C497B5ED9CEBC337D49E69A9C8B0E5C7F71F815572F37498129C596E682034D81695293796EA8FD82817A2E01B6E34783F9E00360AAE59885314A2993806A6B864931921DD1377F87611A0030751D6F886DDBD0D6C4CD3E89910633AD9A8EB8893DBE6CBC8EA5F88A55BA5F7B17CEF58A0DBD88D1B55A6F90513520EDEF995891757604E07B4DF091812DE2CE18BBEE92D975B4F5A0F7A79239C080587B7C1193399EF257FD533294DDEDD623D23F6594DD7BB2F61B457D95AD31F166AF3FF9952540F045AC5B2096A192FF528A346396ED038319EB9F4595AD804DD93F445398F16B1AD135B8089AB5077C418803849A8194D973AB65FC9C553BF027CF5C6F9D35DA059B5881029FA5A0BE4D4B48EF9FE53E0A89CD1A4DA2C5C629A27D5783A29D0A580BF25EFAA281958B33047262A4F93CF0624330E6A4A9F263190A154AA611BE9DEE84FF47A8F97EE94A8D5C99A919D8D093615792AE99D5512F75A04BAF1D31EB61ED07F1AF85070DC926576FB0D55B6558698B3BB1B90268E08FE573B229FBB6BB195910B2B9512DDEBECD95B5C932BA50366E20B62955380593E651B7C9D47908817975BB313B899B8CF3C1BBD57FD448595B2ABC695EEB2F291BE9BC353191F19D4849BD7D552821F6753EBD49E207059F7797BDB13717EB8FB33FC04D0C535B929714C1DD04E3BBAF8EE6C1ED784EDB7B6B29C17D5DE836754C82C21D44F98B9144B2C279656DCD138A22C4A5AF5AA08B9CC4C4A9125A8E663D81C46DB849A762EB3DC575BE483852C206C71186400F76F0AFC775C1F9447C669EC75D9138A03863B2C899EFA416D3E817C8D54EC0770735DEC7C5682CBD5B0813C769B1BDA83FC423CD6D3EFDFEC74CCCCD29209B0E6111E8CDCDC6D152F31ACBCF09FBE33BBEF37AD06D163213F33003D10DF2184A47E154D261E942BEAE90CCD555FF2584ABC5F4D651907A743D174DD77594DB4EBAA25DD8F9C796F5DD4A54D9D1EAB31DF52A85D98DE9D6E12FD253DA91F0BA0A9FC9CADABD3562CAC52E04DB95B99AE048EF65DC89DBD64DE0D28FDCE59223F1514C7DDE11326FFE7D64D3DF59B246DFF6CDF5E0415D2418EAA8BDE035C18AFB1B7E1EE209735292BB9BC1E239D63744A281EAE35D174CA02AC346E5C9BFF44AFE62B3E66503E5109F540EE7B94A13C0E92599E7459D65E9638E4AE93523E5FEE9D6A5E9798AB49CB63971EAE1DD5771A371E3EAB5AE9D3A293EB0E98D9493A1EA16AEEB017196B68DA052EAADD0DEAA685012F0F50E7E67040DFDF171C274AAC4231EF46182F66118D410F5E168AA9C55AB36F6599C3F40593AE9F69DA93B2B2D3536F9052088036888CCF9313A676230ED6DFAA9CB024121D0D1FB95B70606915525FD71C73796D75E25FE717240AACF7E1DFE8501FB630EA753FF8D184303CF9B82FF0DDD5928246A1D00D2288B241DA6A200126B7061A9A9BE00CEDBD7614DCBB402DECCE8FC1CD44303329EA6047285F00246BB5A2FF1134A05C26CE498758ECC061AC5D069096564087E84E9F5F5A1D00866C9EF203C551E082AD9231BE1D9BA08FA873B4B6ADB4E0ACACEE0EEC929430B9627959A56C7790C0EB351BCB62D3B0CBEECCCC04096780CB6D6657807ED2010A2E245874714B0107A6430BC1D8B54122A38B925E0D1B512022B1128310B62146EB130D35FFFE3141AD4E6C151AE6316368EDDEBBA180B1882C05A02F10EFA185ADC474AEAC245199E1F2E091BB1CE180AB05AA6725F9F1D261C774C0ABF0320BAE92996D8D465225AD6027F42E68322CAEA4261B53B30241E35E6C0B8CDA324B619995022AE2825CEC4819F9F98BC2622A0C08780117D27BAF32CB4945B8B26DE1B02EABBC6CF27423F65F8F4577D286631F208F7ED2329CE0414AC84CE0D2BB2A9A8D14A74C92B6E0CDEA198D2203056BB9AB101AE8C30FA9250AD10BDA93352247E9CEF34203462E22D37388D5E35C6782B7532FF02363AC7E1DFF02AEC362AF9CA45E28AF1372A19A545B1679C37EE305535143BC8383EE41CE78B4CC638A2F6C6FDB09385397EA05C046678403B9ABA194E5208F03BC2A902C2E45AAE3BAE880CA49E58493D0AFBF4447AF5923D9EE81951E728913FDA58B0D0244677416608F8CF32C89341D6D0743F38EDAD446E089FB970234645CADE2FE6BD977847C2BF772B46E9AE472EC580B37A840347C6ADE164946AC9479EBBF64114D5094AB652CBDD001FD34B1A975F8169D9154C467D65EC37D6824B72271AFF76B7694CDE7264281775EF4D4A9B9260DA8F674EFAE261F152E6EE4C1E24BDD7BBE5424FFA8632C0551D8D4BE6B24962CC5D4E518A0F59A27B94CC546E8D7B59DE58FE54BABD7999AEC4AE55AEB5D38EACD88D544AC673DB46DF3C57421061C1E53F0A592E0DF4C865B6D85B422D06241284015C6E1BAD53EADB255E8A1982CA5052575F22F3F9E44B8FE35F9A654F45B2AFA25F229BF088F25D2062229AA335C62EB3636E9951D8F82440653AA82D22F69E1966EAB678548B8D15674EC66D8A6DD918683ED2DB58EAEAF66806E606D31B2674688275554CD537D76AD69DA4D44951076BAA86E6AF7CEE346B6A26A3CC6518356D2ABCEC45C70BB06D6241AACE3F665C6E069B6E311D26A66E2EF064141342BF6F86627BCD965AC17026BD2621457BB171160344C518438B71EACA63DF6D7423730A45073B5D38B373B2B5694074A88C73769EEDE7EC810073CAD9367CFA7B2976BE3E3BA51FA7EE769A298C832DEBF07832C62792BCB07D7A0E06AA15F179FF7ACA868D9C48C82F7AFA33ECBEB953007CEEA25A8E04C88A7DCA7825DBFDECB07D867CA7F606F4A27E028E5FD2DFCE6880C2E766526C214D820AA793B8BBD93E83CA586B9EA8C65D89BE1DDBD9B4C9AA8B92E616E72AF0D18B2617469A27D1F28C0E5EBA562358618DBE4B5D5C5BD02291DEC91EAC8B12B292CE2B97BE8FBB6A93325E3D39CA56BE93CA9F3B91BBB8A493E6D5749533DA8C929AC50F022CE9A6937E3B792980D4DE94529348CB1D484E9626648B8572CFC395CA30C9F2ECAB359756BB0E02F4223E93C29724415CBAD0925EF73B2E71B8D7947EE26382053DB199C644A4591013ED9CDA99BE70B3C7489D6A5C3D0DAE6CCC9DDE9682E4A8219BA09692C036314EB4A0EE4470472E46CDA1B2D3529EC553C0A1EEEF182E52CBA3A3D2BE8D96050820A5CA3E34382859A4AA4287C6A393A5CCAB46BDC85E7BCD1DAC7E3DD7C3259CF1AC5A37D27FB7E34FADAA1E48523F375DAE8EDA0C489E41B5AFDE8283ADE99BA0AF6213BED6BE72F9AFCA063E3E4BF335B0C6A697364AE8C5B56E09CF4A56DC1CB6C6E9313A333656B70ADD5639B468B5B766ED20487A5368B99EBA7246A755EDB90EF9233682D8ACBB46B981EE655D6ABC9AA348491BAFB2BC8A26B4C50C1E53BDD6EFE08F005500BC42622C4AA5942CBEE6877F7919D8EEBE4AE7830665202ABF3AEB1D9D5B2A87BE8EA9BFC22D6908C062A2ECF31CBC8CC076E5C02A6ED8F2C16699528EC98B97C2FE518FF9F35D10C48AF0D6452456E4C5729F2192944A88C5EEA623FDB32FB3C9AE9878B3979FFCCA92258E4AB8A926CE72A509D46A28DACEDAB019918A067BCFCE5AA706C643B9CFC61554E70A067BD08654443AD08A3BD13E12EAF4DD8CA7D186EC5AE181B0ADD37E8FC70E57FCDBD3C6E68A1C84AF94D49A7D3E790FB46BD45ECC55936AE3F0D516F88E08A15C75D9263F4195DFD453DCAA3E229976A4DBDEAA092D47B39E40E236B2B6E6494511E2D2A7E9D5436857E352E07C227E33CFE3AE6C7DD57FE884E3021BF0EA34955EE24E1BB06E47DE2EE38EBBAFECC5F45CE3AA9F899F9B2C88E446795288316C7FE7D26A3431D45D89E85A0456D5036001EC6E0AD12CA9139FEC4A190A42B84E73F0CE5BD8FF700BA1F11E0CD9852D6C04F3A28DD2F427095FF62A676B0D7245A8F6669396AC6556E0F64AD3CAE6570197F71A7A03AC716D1AF8C2DB9D992456ECF906A901647AEC21FB561AB150BBFFD2FDFE9643BD3C768DFF0655A25700EB2001178FCEFB70C2FD01775061317B7C6303A7FE02C0521BE40547E12B319D1A90068FEEB4C9B123DC07874FFDEC947FE907ABED11F375C15F0B73B2DF1DC53B430C53D865AA819EB70D8358A39EC10FF60ED7E35CFD0F7358105399C520C1803810A7B6A378AE71CA10B374A82BA05503126BB1E4E40CE7C013EFC0BD81B5816E142B00F178980314142B275AE328240E15936862290E2B77167B37E77E0DD840199753518009736319878E29B34F18831AE7097125E93B5F1B3336EC06C0326D1E437A11A36BB5DE20A3B8CBAC25399A21FB68BA1FDCF65622B79ED91FA196DE235BD670320AB5E423CF36FBF31F699B26EB92E8418A72C9264755758BD770CB268329890FDD3A1A2937557784100D212A1BC6638ADCCEBC2AAF4C9C5FB936F72BA70A22A22773942B3774257985435F2D870364D3D487B32DAB76BF78B74B182D9BEF2C34CB0FF42E37D44AC91D66472F7BD05D746FB44630DB51B1CD53137330B75B3CAAC5C68A3327E336C5F66C0C349F200E4A002AC234F78FB73DDDE6E9347788C9F69C8F98370BEBE71146C73D380770D581715FA037C7EBA5C175B27C394B5BA3EE6D8C0C3BF74BFA779F34C63B9B8C6432066B8C3B577A95D3B5B5CE3C2B1976DFDC29003E77DEFEC30D299C3E0B9AED19B8B74C3E1F82B4FA146DD43F1F3F323AE6AC3B4083407D9B1690DE3FCB9609C6CFA3FC403B73E5DC7D8215425F738B7355F8E84593947699F9B06546CF964B51517CE546F3FD9C2CC4D99549F3CC57456C556E4C83B94A77A4140D4C8F8359065BEA244E6FD798F750D8624E03CED8F809C581510B8D3E251AEBE8530FA6FE57E912705417A67910027FBB554399FB060BDFE056E3234530A85467588BE1F64EDEFB4C594FA74D8F2F669C59ABD34B1B25F4E25A37FB6044A143905AD7761024BD29B45C4FB1A12743AE245D33A6705B78382D5FD39DE58D1351245FD3EC881E4242C3619B5CA52FDFA4D262DB5CD53A8935876207CF10494A25C462772700E0882A67635BE350D85D89A664777A42F48F72106593BD234B091B01671FE72112570203677F26C1375065D868834489C6D37249695F68235AFCC33269D34F14ABCF10CF6AA360938C1E962B6B0F0BBD8A46ADF76BDBD6057DA2D2036BA37C478450AEBA6C1302717701585E6D03DC7A9988B8816C87461AC7C123E76B23C541F3E67FE46F03BF824C92C66671BFC2FC2D681AB2723F28D559EC7968734B700BC9BD4DAC73835B355E10DA77753B5C1FA31910BD751B09317449C34776931EF7A0360A8575CBD19B172BE0E97D5F9947AC6B23767EC7A8B0987DBEB181D3C5A58CA33248830F2BFCE82FE341846FC8AA061C23B484B3BAD06DEA9804853BF3DAE01346D485D3A0AC7A4F016084E7E684B2F8F65E86F7FAEC8D42170C877FB213B4A37F0E869B8F01E90C5D2789BFB19D7B3689FB8FCF7518F0A8680490477A7B0925FC2C90E3237140F1C6649133EF36DBB5DC1C9067AC9E2B44824092FB98326BF511C192DB023C6BA5CF5C935741C6D871C22D936F1DEDFA032322927FECF1A5C1A2F1947B2A2C3347A1AF944F60F88AEBFFC595EF2C2BD2E581219757BB0E2D6FE3FB979F29F095BE100798B36B8528042E349873D15E70FEF15C99EB4E97FE4602269AD33557AC0C21129A2FCBBE83CF7E039C770EF4AF5E9F8C9CB37E750A8787399DABBCC4BE707B329C0B56D46468C48DA0BF2A47A696BC0EA343B39C73D2A380A73F53F627F3EF69A8BBE4BE37D96FBDA7D370B7C50B8FE2ABF344D5B03AAF05ACFB707B27EF7DA6AC277D30A2D02148AD6B6ECE7A13F989AD4BCE189FC48343AEDB9B8A7B1AD5C5ACAFE7AF55DF3204AF839879F785B943ACE7365CA3447AD5AFC3CC4AC206B871B31BDE91A5C48D80B38F5A6F10EFEA87B4934565856FA218B6D3BECE95ABF3D5B64FE604FC0999F1B6FB4B6724C34719B8B77335C111DECBB813F475643F1E34BAFB3656CDCF1488BABBFCDF1EFA855DBA7FFD05548D48CBBBBF0DCC7E0426C2BC131DFFAE25ADD7BCD31B462697F1AABDEF45313DD8270CBEE707E056242CD8BE13B84AD4B764DFBE93AF7DDE172DE1BFD7502BAFE8FDCEBFC337C5E34EDFE8C087FB01139A6702C14FE252C65119A4C1073BDE03DC8DBCC377F552572E26F6C597C472D5A181BEC6EF95563F0C9BBEC7AFCC7AEC1FDB23C83BE44ECEA71BA5C8572063EC78E196C9B7159699A3D057CA27A8F661C99049CB7F9615E9B2C090CBAB4CD4221EC681CB6B187E4FA4B4ACCEB79C3F95F16F67CF27D7093F0F4FE8CFBF2B6A3234E246D05F1A31CA13B119D0772FE95D9A6C4CD117FBB941E9244CD12BE03923B1430FD2FBF1881FE9CB5BCF7F8F703B61C247D0635873AA17FCA4D34367B086764C24D4F3156939ED73E2D4C31CFFBE92AA66D2F3CCBCFB82DC21D6F327DFE3B34422D82B5FFF9EF1CE0CDB0B1006608827A5DCCFB517FBBFD1FADE6B9B8E4E2BDABBDF13DF6F28857840E0EB389F49953BBDE03F9DDF7584F60DE103A0BD5551A024E0EBAAACE1CEE3C2E287272FFFDFBD56E3934AAB1F864DDFE35740A69AB4E3A8E3ABB69265D1A857E4EF1E35FE76FA45E7E3ECE0EB8D8F8FE7BF8D18E5C9D80CE8BBEB849FC727F4E7DF60BD6743F873E75B51834C442880EB5F5F77A1710D78ED1F508CBABCFF90F1FBE6D7ABEF82D8F29B4C7F17204F17F35B401007D01099F363510B9885180BF4EBD3A8310E9D73F573EA797F5D48F9F527EAFC496ECF7DF6DF960582C2A0A3F72B41DC02BB88B4F91FF5BCBF6EA4FCFA93BE83BF8CEAA6FB0B20E6DE183804FC7B59D5F023324AFC5B058AAC5DC4B1FE03 +hex(toString(anyState(x))): 01AE1F0100E80700000000B89901B4C50F022CBB0E02F4208803683BDE03DC63B4048CC8AA061C36EC06C0401007D05E4A09BC5D560C3085C80FA04C7F17205BC218E0BBAC18609AED19B81F1A1C94E68A1C8468BA1FDCC9EF203C38B925E0E17F2828DD5928243B7929806B852804AC9E2B4474A82BA0FD9C2CC4C2FC2D68234530A86A3431D42B6A3234958B33047214353830553514D0C5364CE22D373826C137503E3438285B4238E4DD5639B4E24439A054443AD030433B0819723B847AC33C3C45313DD8D0743F3895563F0CB5694074077C4188BBF64114190A42B893DF41789724415C13FA43E81E074408C1F9447CCC57456CF0D6452440F045AC85B244A4200E4A00258E4AB837FA4CC451834C44DBD64DE056DE4D64187E4FA4C3FF50505EE65150E766526C765C53B07CD853CC9AF855B407E0562455A25700CC69585428D559EC68235AFC44A45910A6705B785B355E101006608882F661183A676230B24962CCC1F462B056D46468ADE164940E7E6704DDD168E461AA6A28CC626C9CF2476C04DF846CA8349F73448F0675609DDF7584B94A77A400F17898FC87793C0C6B7A50174A7CF87B2E7D64223C7E7C0DCC7E0417F87FF8A42E8088632981440D4381E000FA83B47C478450EA3C84805577841051A086BCA0C08780D8F58720C6638ADCF14F8B54A25A8E04B5D38EACEFE08F00873A91F890D2914CC62792BC9F2192944A2993804B9192F4E92996D8DC7A9988DB9D992415D69A04AB809A303B899B8C40A69AB4868D9C48DA059B5864E59B08586B9EA8CE189FC49138A0380CDEA1987D30A2D04CBFA2B4365CA34468DAA3E0DE91A5C435B0A844C4E5A8C49EDAA9C4C274AAC4D0DEAA68F662AB300414AC843557AC0C9250AD10397EADC0502BAFE820CBAF8086E6AF7C88F0B15CE684B2F8F32CB494773EB53C089FB97011F5BA7C508CBABC9643BD3C4AC9BD54BCC4BE70BE83BF8CBB3AC0088499C06C35E6C0B87D79C05CA902C2E42CCBC234ECCCC0400344C51826B4C50C9914C7084E66C9E01982CA503659CB982DE2CE1858B0D0249B21D10470E9D2A40364D3D472A8D424DBD0D6C44AEBD65CABEED7CC9B36D8789951D8F8C5EED6E81DDBD9B405D3D9280986DCB852CBDD0091B0DDA020E6DE181976DFDC86CCDFB0C7E1DFF0B99AE0482700E088B5D8DD0C3F91E258275AE3289682E4A8B1E4E40CBE86E6789EEDE7EC8F01E90C1BF0EA342925EB2C2063EC784FFDEC94BE9DEE847518F0A8F1D4F26430C9F2ECA2ECF31CF80FF3F875B4F5A088C9F69CD798F7503F65F8F45ED7F8B082B4FA148FCEFB700534FCA8CCE8FC1C5242FC584C3AFDE83EB4FCECF6C6FDB01BEFFD245BD8FF70569700EDC05A02F1B7060691E7830665D4790881F88E08A17A7B0925E5010A91939B0D1D209B0E6129890FDDA7C7106951CF1191551413A106AA15F1D6761661F27E1B59D34B1B25D9231BE144B51C89B2DF1DC531921DD1EAB31DF506671F8177A91FE59ED91FA199C520C18D312115F1881FE952871FB9BD262145F32A2385E03923B1761024BD288B241D097125E965D02631DD0C27299F742741DF6F28852B112831312828AD8DD629B5A93B2B2D9DCB2CE90AD12CA9F73B2E71BB5A2FF15EEB2F291A003075D5512F75E12B319D9B6E311D7AF332DD4F2A35AD642F36A15DE836753AEB362192C03631E4BE37D9156939ED5CD53A8945073B5DAE9D3A293D323C0D8F703B6194F83D6949A73DC98FB73DDD237140F19C3F4059CB0241210F30417DFBB941E9543943E5FB6044A119A545B10DD348257FD4485974B64975622C4AA5389F4995BD234B09629D4B3975554CD57EE94A8D0B265095964B51518E5054DDFD05548DA0BD5551A6FE57E95B6558699EF257FD4C9C5FB9EA4261B5A8F661C9DBD7614D31EB61ED6B7061A907DE61397B5D6581B69265D12B496741C5D0690941256A0D023C6BA598326BF55C227129624B73C1738B735509317449907A743DA0FE761D027177017B71795174257985877F7919DDAC79D5E4127C9DCD4F7D3D4F917D2937E77E0DEA797F5D74057F6553518009975F8169C0BD81B5E263820570D58171298C832DA98484CD0CD9852DDFA88671E3508739795288313E0A89CD8FFE8905C66D8A6D4C688B9944F98B91A3C48C7999528EC90C149365D8D0936178A194619C3F95F1BE8D9605C1A49709510B98856CE49875A8B0987D947699F97E299C3168AA9C55F4189D59D3529EC55FFF9EF158A39EC15F77A17154D7A175C629A27D5C1FA319ECF1A5C1EDF3A6DDE550A9C95B3CAAC5D865AA817A03AC71058AAC5D9396AC658283ADE9B8CBAC251DFFAE25A775B10DBB9AB1018E62B3B5C12DB3D5EAB6B4E57196B68D4791B8E9A596BB016430BC1DFBB6BB1933ECBEB94A13C0E98632C0557335C111D4E6C1511061C1E5A9BFC22DBD42C3AD3DD7C325DEFEC30DEBA5C17541CFC51DE252C651461AC7C10DF4C865700BC9BDEEB4C9B13562CAC56ABACA59D44AC91D9348CB1D26A3CC65014CCF356B1AD135BD88D1B52C2BD2E57A95D3B5A621D4419DA4D449C472D5A1EBDCD521E350D85D41C6D8717C6DD8C51394D9D1EF36DBB53388DB1D7825DBFD73E5DC7D6916DF2D82EFDFD9CA63DF6DEC5AE18188B2E1191152E32DAE7EE4818D18E5C9B15DE511DE2FE6BDB2B6E649D732E5E1F7F5EA6DECE0EB8D92DEEBDDBBAFECC55BA3EE6DB981EE65CEE0EEC99223F1513191F19DF97CF2E5ED11F375728EF30512EAF4DD0D01F55D84E9F5F5C796F5DD4D8FF6959879F785CED8F8091563FB71E604FC092A75FCA1FD2CFDD1326FFE7D23E5FEE9CE2EFF957A2E01B6A05C04662FA504DE9EA604725AA706C6A43C0ACA0A580BF233800B96676B0D725C3D0DAE5A100E5E86400F76A6791002B26A11FAEBE71146FB01139AD16E141E4C21189E5D2418EAF263190AEC881E42FCDF1EFA4AAB1F861A4D2186552821F6E07C227E4CD4221E970423EAEDD623D22D062412A82D22F68D3E251A6862290EE5C02A6E0D542B62BF772B46509E2A02EF182E520A5131E6D92431E6D670320A6F9131BED3A8310E07BF33823F0C321A8C643206431335729AA335C6F9233682A697364ABE483852270839DE5E3D39CA3F323AE67BC13BDAFBE33BBE9C263FDA46E9405E92E8418ACC59428E8F8742CAD63744A2FBF4447A654F45B2F9CA45E24470472EDA0C489EED20487ACF10494AEAFC496E1B4B4A3ADC474AEA873B4B6AA548497A1C774C0ABF824C9209CF4A569FBC4D9ABA194E5294DB4EBABFF44AFEE1F64EDE59154FB61999502232BA503692FF528A1BAD53EA6927550AE7515676F1BF5692F552572ED2DB58EA8D7B59DEB29F5A120C535B924A065C1EF7C45D0A2FE95D9A49F05F9AE00360AA9B9260DA75B7626601FB630EA901647A76DF64EA4BA164B6073F66EE26D46C8ABAD06DEAD15E70FE19AA70A2B39C73D2782B75329492759616D077EEB6A378AE837D791E5293796E5CCB7956B19D7B369B8A7B1AD9367CFAD31E7DA2D6057DA266747E0EDB9F7E9ED6027F425F5A80CA960582C28D07821A648B857267B086769BF088F20BBD8A4660938C1E87DF8DC2125A8E66FAEC8D42B019918A5149927ECC55936A29F095BE6E9296AE6B4797223E229976BD7999AE27959A56407D9B168AB49CB6174CA02AE3FBA05E1EF7A0369493A1EAD033A24E880CA49EFCD5A43AB05AA6722EA6A62AB849A7625302A6FAEC5EA8362E4BA7862A47A696E4FAA9C60633AD9ABC74ADC60EF4AF5E6B0EB04644D5B03AC580B37AFB3AB7BE295EB9A2A7A1BABABE6CBC8E6ED6BCAAF61CBCD2B351BCB6E942BEAE8CF4BEEE1CFFBE92F5BCBF6EFE02C052CC4AC20650ECC22E2B85C4FEE336C5F64C33C606070DC926627BCD9698CCCD4A08F8CF32A9A8D14A97AAD1B6DBEBD132A509D46A13BED6BE90ECD8D67B5ED9CEC673DB4613A8DB02A2E2DCA6512DDEBEF998DE06B246DFF62349DFE2337DE166AAACE1CE734EE20E37C5E34EC596E682E616E72A1554E70AAF87E8929615E9B27AF9E922566CE9A6CD7EE97A8EDDEBBA3F69ECBE5F44ED62EB97ED5E7D9FEE1E7AA5EE3A17D1EFFAE261F152C541F3E64991F392B1CCF48E847CF5827CA7F6066708F84ACCBCFB825BE3FC0A3B03FD36D195FC621E35FE764E97FE46271AFF76F52C023341DC02BB184303CF3D2B04AF0908049FDD6A05AB553805938359065B99FB060BE207059F76D7075B8103087331F208F71F2E091B7E750A87F0BA0A9FBE510DE38FC70E57C48B0E8F5A6F10EF03E5109FE7211257163213F3F0641413EFA416D303D817E357D517D3D19B172B038319EBAC4A1A3F61CA1A170B6F1BE3B92E1B7F3E7722F7FA7C24E34B6724C3DC6026F71B462697707B27EF53F627F33BF027CF72642817B1A127434592294BF3282BFBED2C2BBBBB0E2D6F05162D1FCA6B2E0B4D762EDB5CA52FDF5061317BAA9E31FB25A131DFF13E32CB2A2C3347EF2C34CB69E536D39156377BE9313A330EF63A2703663D93063E3E4BD7093F0FBCEC45C7BA7246A786944603092D47B31D7047F3A348491B33054A87F2184A474BB94A3FA0BE4D4B9B8E4E2B888E4F5B1AB150BBE81951E7FDE7519F1E48523FC6D152F3B678548BE7AF55DF5EBA5623F815572FC080587B4B5D5C5BDAD55C53A42C5EB3BDC85E7BDC535E43954262FF3CF062435A7664CFF475643F34146663D7AE66DB31F166AF91E567877A63683360BD6743008E69437A596B3348766CEB8C326CA71BB06E4740476F7F99BE70B3DD5771A38F2271D394D973ABD05D746F4BFA779FD66578074EC0770776BF78B77D3E790F58667AC76ECE7A13A0AC7A4FBA5F7B1773457CC773297E7B37D27FB7CBBE83CFFF2584ABB376841B4565856FE245874760F88AEB55758BD7C5A58CA330658D8B9DE58D13CADD8E33A74D8F2F9F3B91BB735292BB6E7C9287736D932B393294CB3F4195DFBECE95ABD5749533C73796D7159699A317469A27247E9CEFE69A9C8BEB1D9D5BC4819F9FEB849FC7AF5AA08B5C20A19B7FC1A0FB0F59A27B9F899F9B7A11A36B87C6A3930A22A2273E3BA51F790CA5A36361A6BF0745A5EFD72EA8BBFEF3A84FB1BDA83FCD56A86B5873AA17A016AA3F4F14ABCFE6D7ABEF9947AC6BC91EAC8B7240AACF4224AE3FC03DB2FF9878B3978E29B34FB213B4A39E76B7A7A793B8BBE211B93B04E3BBAF682CBD5BD869BECF2B97BE8FD1A7BE9794C7BEDBB659C4DF70B7C50B9609C6CF4489C6D3373EC9031A31CA13BD5FCCFB3656CDCF656DCD13E44ECEA7184FCE3B51B1CD5341AACE3FE86CD01B8E5FD2DFB130D35F2A4FD353B84AD4B7E606D31B6C7DD57FA7E9D543500BD59F0456D50324BDD7BBEAF5D843804DD93F05D2D84351C3DA7F3671DBFF3B18DBFF784EDB7BAF7DDE17E20BDF770268E08FF3DAE0137604E07BE9D6E12F27DFE3B3F3F9E44BD3CAE657E41CE78B99BEE72F2BFCE82F9D65E9631B02EABB3717EB8FA0B5EBFB7D65EC37C0A6EC5F3520EDEF5B65EC17CC7AEC1FF185EFFB59D5F02336FBF31F7A42F48FD5F1F4F78DD2F4279646F6DF7BB2F61B518FF9F31DEDFA03B517FBBF4108FB73C18AFB1BE35CFD0FA623FDB31E93FD133EFDFEC7272FFFDF12010000000000000000 +hex(toString(any(x))): 0100E80700000000B89901B4C50F022CBB0E02F4208803683BDE03DC63B4048CC8AA061C36EC06C0401007D05E4A09BC5D560C3085C80FA04C7F17205BC218E0BBAC18609AED19B81F1A1C94E68A1C8468BA1FDCC9EF203C38B925E0E17F2828DD5928243B7929806B852804AC9E2B4474A82BA0FD9C2CC4C2FC2D68234530A86A3431D42B6A3234958B33047214353830553514D0C5364CE22D373826C137503E3438285B4238E4DD5639B4E24439A054443AD030433B0819723B847AC33C3C45313DD8D0743F3895563F0CB5694074077C4188BBF64114190A42B893DF41789724415C13FA43E81E074408C1F9447CCC57456CF0D6452440F045AC85B244A4200E4A00258E4AB837FA4CC451834C44DBD64DE056DE4D64187E4FA4C3FF50505EE65150E766526C765C53B07CD853CC9AF855B407E0562455A25700CC69585428D559EC68235AFC44A45910A6705B785B355E101006608882F661183A676230B24962CCC1F462B056D46468ADE164940E7E6704DDD168E461AA6A28CC626C9CF2476C04DF846CA8349F73448F0675609DDF7584B94A77A400F17898FC87793C0C6B7A50174A7CF87B2E7D64223C7E7C0DCC7E0417F87FF8A42E8088632981440D4381E000FA83B47C478450EA3C84805577841051A086BCA0C08780D8F58720C6638ADCF14F8B54A25A8E04B5D38EACEFE08F00873A91F890D2914CC62792BC9F2192944A2993804B9192F4E92996D8DC7A9988DB9D992415D69A04AB809A303B899B8C40A69AB4868D9C48DA059B5864E59B08586B9EA8CE189FC49138A0380CDEA1987D30A2D04CBFA2B4365CA34468DAA3E0DE91A5C435B0A844C4E5A8C49EDAA9C4C274AAC4D0DEAA68F662AB300414AC843557AC0C9250AD10397EADC0502BAFE820CBAF8086E6AF7C88F0B15CE684B2F8F32CB494773EB53C089FB97011F5BA7C508CBABC9643BD3C4AC9BD54BCC4BE70BE83BF8CBB3AC0088499C06C35E6C0B87D79C05CA902C2E42CCBC234ECCCC0400344C51826B4C50C9914C7084E66C9E01982CA503659CB982DE2CE1858B0D0249B21D10470E9D2A40364D3D472A8D424DBD0D6C44AEBD65CABEED7CC9B36D8789951D8F8C5EED6E81DDBD9B405D3D9280986DCB852CBDD0091B0DDA020E6DE181976DFDC86CCDFB0C7E1DFF0B99AE0482700E088B5D8DD0C3F91E258275AE3289682E4A8B1E4E40CBE86E6789EEDE7EC8F01E90C1BF0EA342925EB2C2063EC784FFDEC94BE9DEE847518F0A8F1D4F26430C9F2ECA2ECF31CF80FF3F875B4F5A088C9F69CD798F7503F65F8F45ED7F8B082B4FA148FCEFB700534FCA8CCE8FC1C5242FC584C3AFDE83EB4FCECF6C6FDB01BEFFD245BD8FF70569700EDC05A02F1B7060691E7830665D4790881F88E08A17A7B0925E5010A91939B0D1D209B0E6129890FDDA7C7106951CF1191551413A106AA15F1D6761661F27E1B59D34B1B25D9231BE144B51C89B2DF1DC531921DD1EAB31DF506671F8177A91FE59ED91FA199C520C18D312115F1881FE952871FB9BD262145F32A2385E03923B1761024BD288B241D097125E965D02631DD0C27299F742741DF6F28852B112831312828AD8DD629B5A93B2B2D9DCB2CE90AD12CA9F73B2E71BB5A2FF15EEB2F291A003075D5512F75E12B319D9B6E311D7AF332DD4F2A35AD642F36A15DE836753AEB362192C03631E4BE37D9156939ED5CD53A8945073B5DAE9D3A293D323C0D8F703B6194F83D6949A73DC98FB73DDD237140F19C3F4059CB0241210F30417DFBB941E9543943E5FB6044A119A545B10DD348257FD4485974B64975622C4AA5389F4995BD234B09629D4B3975554CD57EE94A8D0B265095964B51518E5054DDFD05548DA0BD5551A6FE57E95B6558699EF257FD4C9C5FB9EA4261B5A8F661C9DBD7614D31EB61ED6B7061A907DE61397B5D6581B69265D12B496741C5D0690941256A0D023C6BA598326BF55C227129624B73C1738B735509317449907A743DA0FE761D027177017B71795174257985877F7919DDAC79D5E4127C9DCD4F7D3D4F917D2937E77E0DEA797F5D74057F6553518009975F8169C0BD81B5E263820570D58171298C832DA98484CD0CD9852DDFA88671E3508739795288313E0A89CD8FFE8905C66D8A6D4C688B9944F98B91A3C48C7999528EC90C149365D8D0936178A194619C3F95F1BE8D9605C1A49709510B98856CE49875A8B0987D947699F97E299C3168AA9C55F4189D59D3529EC55FFF9EF158A39EC15F77A17154D7A175C629A27D5C1FA319ECF1A5C1EDF3A6DDE550A9C95B3CAAC5D865AA817A03AC71058AAC5D9396AC658283ADE9B8CBAC251DFFAE25A775B10DBB9AB1018E62B3B5C12DB3D5EAB6B4E57196B68D4791B8E9A596BB016430BC1DFBB6BB1933ECBEB94A13C0E98632C0557335C111D4E6C1511061C1E5A9BFC22DBD42C3AD3DD7C325DEFEC30DEBA5C17541CFC51DE252C651461AC7C10DF4C865700BC9BDEEB4C9B13562CAC56ABACA59D44AC91D9348CB1D26A3CC65014CCF356B1AD135BD88D1B52C2BD2E57A95D3B5A621D4419DA4D449C472D5A1EBDCD521E350D85D41C6D8717C6DD8C51394D9D1EF36DBB53388DB1D7825DBFD73E5DC7D6916DF2D82EFDFD9CA63DF6DEC5AE18188B2E1191152E32DAE7EE4818D18E5C9B15DE511DE2FE6BDB2B6E649D732E5E1F7F5EA6DECE0EB8D92DEEBDDBBAFECC55BA3EE6DB981EE65CEE0EEC99223F1513191F19DF97CF2E5ED11F375728EF30512EAF4DD0D01F55D84E9F5F5C796F5DD4D8FF6959879F785CED8F8091563FB71E604FC092A75FCA1FD2CFDD1326FFE7D23E5FEE9CE2EFF957A2E01B6A05C04662FA504DE9EA604725AA706C6A43C0ACA0A580BF233800B96676B0D725C3D0DAE5A100E5E86400F76A6791002B26A11FAEBE71146FB01139AD16E141E4C21189E5D2418EAF263190AEC881E42FCDF1EFA4AAB1F861A4D2186552821F6E07C227E4CD4221E970423EAEDD623D22D062412A82D22F68D3E251A6862290EE5C02A6E0D542B62BF772B46509E2A02EF182E520A5131E6D92431E6D670320A6F9131BED3A8310E07BF33823F0C321A8C643206431335729AA335C6F9233682A697364ABE483852270839DE5E3D39CA3F323AE67BC13BDAFBE33BBE9C263FDA46E9405E92E8418ACC59428E8F8742CAD63744A2FBF4447A654F45B2F9CA45E24470472EDA0C489EED20487ACF10494AEAFC496E1B4B4A3ADC474AEA873B4B6AA548497A1C774C0ABF824C9209CF4A569FBC4D9ABA194E5294DB4EBABFF44AFEE1F64EDE59154FB61999502232BA503692FF528A1BAD53EA6927550AE7515676F1BF5692F552572ED2DB58EA8D7B59DEB29F5A120C535B924A065C1EF7C45D0A2FE95D9A49F05F9AE00360AA9B9260DA75B7626601FB630EA901647A76DF64EA4BA164B6073F66EE26D46C8ABAD06DEAD15E70FE19AA70A2B39C73D2782B75329492759616D077EEB6A378AE837D791E5293796E5CCB7956B19D7B369B8A7B1AD9367CFAD31E7DA2D6057DA266747E0EDB9F7E9ED6027F425F5A80CA960582C28D07821A648B857267B086769BF088F20BBD8A4660938C1E87DF8DC2125A8E66FAEC8D42B019918A5149927ECC55936A29F095BE6E9296AE6B4797223E229976BD7999AE27959A56407D9B168AB49CB6174CA02AE3FBA05E1EF7A0369493A1EAD033A24E880CA49EFCD5A43AB05AA6722EA6A62AB849A7625302A6FAEC5EA8362E4BA7862A47A696E4FAA9C60633AD9ABC74ADC60EF4AF5E6B0EB04644D5B03AC580B37AFB3AB7BE295EB9A2A7A1BABABE6CBC8E6ED6BCAAF61CBCD2B351BCB6E942BEAE8CF4BEEE1CFFBE92F5BCBF6EFE02C052CC4AC20650ECC22E2B85C4FEE336C5F64C33C606070DC926627BCD9698CCCD4A08F8CF32A9A8D14A97AAD1B6DBEBD132A509D46A13BED6BE90ECD8D67B5ED9CEC673DB4613A8DB02A2E2DCA6512DDEBEF998DE06B246DFF62349DFE2337DE166AAACE1CE734EE20E37C5E34EC596E682E616E72A1554E70AAF87E8929615E9B27AF9E922566CE9A6CD7EE97A8EDDEBBA3F69ECBE5F44ED62EB97ED5E7D9FEE1E7AA5EE3A17D1EFFAE261F152C541F3E64991F392B1CCF48E847CF5827CA7F6066708F84ACCBCFB825BE3FC0A3B03FD36D195FC621E35FE764E97FE46271AFF76F52C023341DC02BB184303CF3D2B04AF0908049FDD6A05AB553805938359065B99FB060BE207059F76D7075B8103087331F208F71F2E091B7E750A87F0BA0A9FBE510DE38FC70E57C48B0E8F5A6F10EF03E5109FE7211257163213F3F0641413EFA416D303D817E357D517D3D19B172B038319EBAC4A1A3F61CA1A170B6F1BE3B92E1B7F3E7722F7FA7C24E34B6724C3DC6026F71B462697707B27EF53F627F33BF027CF72642817B1A127434592294BF3282BFBED2C2BBBBB0E2D6F05162D1FCA6B2E0B4D762EDB5CA52FDF5061317BAA9E31FB25A131DFF13E32CB2A2C3347EF2C34CB69E536D39156377BE9313A330EF63A2703663D93063E3E4BD7093F0FBCEC45C7BA7246A786944603092D47B31D7047F3A348491B33054A87F2184A474BB94A3FA0BE4D4B9B8E4E2B888E4F5B1AB150BBE81951E7FDE7519F1E48523FC6D152F3B678548BE7AF55DF5EBA5623F815572FC080587B4B5D5C5BDAD55C53A42C5EB3BDC85E7BDC535E43954262FF3CF062435A7664CFF475643F34146663D7AE66DB31F166AF91E567877A63683360BD6743008E69437A596B3348766CEB8C326CA71BB06E4740476F7F99BE70B3DD5771A38F2271D394D973ABD05D746F4BFA779FD66578074EC0770776BF78B77D3E790F58667AC76ECE7A13A0AC7A4FBA5F7B1773457CC773297E7B37D27FB7CBBE83CFFF2584ABB376841B4565856FE245874760F88AEB55758BD7C5A58CA330658D8B9DE58D13CADD8E33A74D8F2F9F3B91BB735292BB6E7C9287736D932B393294CB3F4195DFBECE95ABD5749533C73796D7159699A317469A27247E9CEFE69A9C8BEB1D9D5BC4819F9FEB849FC7AF5AA08B5C20A19B7FC1A0FB0F59A27B9F899F9B7A11A36B87C6A3930A22A2273E3BA51F790CA5A36361A6BF0745A5EFD72EA8BBFEF3A84FB1BDA83FCD56A86B5873AA17A016AA3F4F14ABCFE6D7ABEF9947AC6BC91EAC8B7240AACF4224AE3FC03DB2FF9878B3978E29B34FB213B4A39E76B7A7A793B8BBE211B93B04E3BBAF682CBD5BD869BECF2B97BE8FD1A7BE9794C7BEDBB659C4DF70B7C50B9609C6CF4489C6D3373EC9031A31CA13BD5FCCFB3656CDCF656DCD13E44ECEA7184FCE3B51B1CD5341AACE3FE86CD01B8E5FD2DFB130D35F2A4FD353B84AD4B7E606D31B6C7DD57FA7E9D543500BD59F0456D50324BDD7BBEAF5D843804DD93F05D2D84351C3DA7F3671DBFF3B18DBFF784EDB7BAF7DDE17E20BDF770268E08FF3DAE0137604E07BE9D6E12F27DFE3B3F3F9E44BD3CAE657E41CE78B99BEE72F2BFCE82F9D65E9631B02EABB3717EB8FA0B5EBFB7D65EC37C0A6EC5F3520EDEF5B65EC17CC7AEC1FF185EFFB59D5F02336FBF31F7A42F48FD5F1F4F78DD2F4279646F6DF7BB2F61B518FF9F31DEDFA03B517FBBF4108FB73C18AFB1BE35CFD0FA623FDB31E93FD133EFDFEC7272FFFDF12010000000000000000 diff --git a/tests/queries/0_stateless/02418_aggregate_combinators.reference b/tests/queries/0_stateless/02418_aggregate_combinators.reference index 5ed2ca5fd35d..6a122fad54dc 100644 --- a/tests/queries/0_stateless/02418_aggregate_combinators.reference +++ b/tests/queries/0_stateless/02418_aggregate_combinators.reference @@ -1,8 +1,8 @@ -{1:'\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'} -{1:[{1:['\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:','\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:']}]} -[['\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:','\0\0\nuð¨h�GÕñô÷š ‘\rÓH%DradÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'],[]] -[[{1:'\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'}],[]] -['\0\0\n\0\0\0\0\0\0\0\0uð¨h�GÕñô÷š ‘ÎØø\tÅ�Q sE|DZ³´è,ËÂ4ü¼V´ÝÑhäË)tuULÕ7×jևƣ“¥Ì«Fçƒe *¿:'] +{1:'\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç\0\0\0\0\0\0\0\0'} +{1:[{1:['\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç\0\0\0\0\0\0\0\0','\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç\0\0\0\0\0\0\0\0']}]} +[['\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç\0\0\0\0\0\0\0\0','\0\0\nuð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhä\rÓH%uULÕsE|Ç'],[]] +[[{1:'\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç\0\0\0\0\0\0\0\0'}],[]] +['\0\n\0\0\0\0uð¨,ËÂ4‡Æ£“Õñô÷çƒeÎØø\tÝÑhäuULÕsE|Ç\0\0\0\0\0\0\0\0'] 10 {1:10} {1:[{1:[10,10]}]} diff --git a/tests/queries/0_stateless/02428_combinators_with_over_statement.reference b/tests/queries/0_stateless/02428_combinators_with_over_statement.reference index 27d1e82bac18..155bf1fe69e3 100644 --- a/tests/queries/0_stateless/02428_combinators_with_over_statement.reference +++ b/tests/queries/0_stateless/02428_combinators_with_over_statement.reference @@ -1,50 +1,50 @@ -{1:'\0\0Âÿ¾÷#×\0”'} -{1:'\0\0U0œ5WÇï'} -{1:'\0\0qäÍè±Ý&'} -{1:'\0\0–_±£ÿµýÀ'} -{1:'\0\0Ámsˆ~žžƒ'} -{1:'\0\0xZå„’XZ'} -{1:'\0\0bšhBÄj?š'} -{1:'\0\0?õ�¢³_'} -{1:'\0\0\r1Å\fé*†'} -{1:'\0\0ïç%§ò*Ÿ¹'} -['\0\0Âÿ¾÷#×\0”'] -['\0\0U0œ5WÇï'] -['\0\0qäÍè±Ý&'] -['\0\0–_±£ÿµýÀ'] -['\0\0Ámsˆ~žžƒ'] -['\0\0xZå„’XZ'] -['\0\0bšhBÄj?š'] -['\0\0?õ�¢³_'] -['\0\0\r1Å\fé*†'] -['\0\0ïç%§ò*Ÿ¹'] -['\0\0¬¿ûêRÚP','\0\0\0'] -['\0\0o’°�³�6','\0\0\0'] -['\0\0G¢+ˆfzø‰','\0\0\0'] -['\0\0³Ç~`ÙK´±','\0\0\0'] -['\0\02ê•ÛìÏÆ','\0\0\0'] -['\0\0sú»…,@\t','\0\0\0'] -['\0\0?j8òWi','\0\0\0'] -['\0\0WuÞÉžx5','\0\0\0'] -['\0\0DÚÎN‹¦ƒº','\0\0\0'] -['\0\0ñ珞JêCû','\0\0\0'] -[{1:['\0\0Âÿ¾÷#×\0”']}] -[{1:['\0\0U0œ5WÇï']}] -[{1:['\0\0qäÍè±Ý&']}] -[{1:['\0\0–_±£ÿµýÀ']}] -[{1:['\0\0Ámsˆ~žžƒ']}] -[{1:['\0\0xZå„’XZ']}] -[{1:['\0\0bšhBÄj?š']}] -[{1:['\0\0?õ�¢³_']}] -[{1:['\0\0\r1Å\fé*†']}] -[{1:['\0\0ïç%§ò*Ÿ¹']}] -{1:'\0\0Âÿ¾÷#×\0”'} -{1:'\0\0U0œ5WÇï'} -{1:'\0\0qäÍè±Ý&'} -{1:'\0\0–_±£ÿµýÀ'} -{1:'\0\0Ámsˆ~žžƒ'} -{1:'\0\0xZå„’XZ'} -{1:'\0\0bšhBÄj?š'} -{1:'\0\0?õ�¢³_'} -{1:'\0\0\r1Å\fé*†'} -{1:'\0\0ïç%§ò*Ÿ¹'} +{1:'\0\0—¿Ï{'} +{1:'\0\0)4u™'} +{1:'\0\0‘ºå«'} +{1:'\0\0]¯~û'} +{1:'\0\0å�\0'} +{1:'\0\0åØá�'} +{1:'\0\0ö�@ç'} +{1:'\0\0•Õ,¼'} +{1:'\0\0âb7ß'} +{1:'\0\0ýL¥T'} +['\0\0—¿Ï{'] +['\0\0)4u™'] +['\0\0‘ºå«'] +['\0\0]¯~û'] +['\0\0å�\0'] +['\0\0åØá�'] +['\0\0ö�@ç'] +['\0\0•Õ,¼'] +['\0\0âb7ß'] +['\0\0ýL¥T'] +['\0\0â\rdá','\0\0\0'] +['\0\0¿3Ì0','\0\0\0'] +['\0\0íºæ','\0\0\0'] +['\0\0äƒ�!','\0\0\0'] +['\0\0=¡èl','\0\0\0'] +['\0\0×¾','\0\0\0'] +['\0\0¼_Ê','\0\0\0'] +['\0\0"0åZ','\0\0\0'] +['\0\0\fC˜$','\0\0\0'] +['\0\0cxšŸ','\0\0\0'] +[{1:['\0\0—¿Ï{']}] +[{1:['\0\0)4u™']}] +[{1:['\0\0‘ºå«']}] +[{1:['\0\0]¯~û']}] +[{1:['\0\0å�\0']}] +[{1:['\0\0åØá�']}] +[{1:['\0\0ö�@ç']}] +[{1:['\0\0•Õ,¼']}] +[{1:['\0\0âb7ß']}] +[{1:['\0\0ýL¥T']}] +{1:'\0\0—¿Ï{'} +{1:'\0\0)4u™'} +{1:'\0\0‘ºå«'} +{1:'\0\0]¯~û'} +{1:'\0\0å�\0'} +{1:'\0\0åØá�'} +{1:'\0\0ö�@ç'} +{1:'\0\0•Õ,¼'} +{1:'\0\0âb7ß'} +{1:'\0\0ýL¥T'} diff --git a/tests/queries/0_stateless/03821_uniq_speedup_boundaries.reference b/tests/queries/0_stateless/03821_uniq_speedup_boundaries.reference index 3f51c22a7fe3..271f95d7e12a 100644 --- a/tests/queries/0_stateless/03821_uniq_speedup_boundaries.reference +++ b/tests/queries/0_stateless/03821_uniq_speedup_boundaries.reference @@ -3,4 +3,4 @@ 7 8 17 -100314 +100315 diff --git a/tests/queries/0_stateless/03821_uniq_speedup_boundaries.sql b/tests/queries/0_stateless/03821_uniq_speedup_boundaries.sql index d3e2c2c801a0..282fd25d76de 100644 --- a/tests/queries/0_stateless/03821_uniq_speedup_boundaries.sql +++ b/tests/queries/0_stateless/03821_uniq_speedup_boundaries.sql @@ -8,6 +8,6 @@ SELECT uniq(number) FROM numbers(8); -- Exactly batch_size SELECT uniq(number) FROM numbers(17); -- Two full batches + 1 -- Force resize during batching (depends on UNIQUES_HASH_MAX_SIZE) --- Note this returns 100314 due to BJKST thinning (expect ~100000, actual varies). --- This is expected and deterministic. +-- Note this returns 100315 due to BJKST thinning (expect ~100000, actual varies). +-- This is expected and the same as previous releases SELECT uniq(number) FROM numbers(100000); diff --git a/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.reference b/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.reference index baea759ddb6c..19efe1c92ab8 100644 --- a/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.reference +++ b/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.reference @@ -1,8 +1,8 @@ AggregateFunction(1, uniq, UInt8) AggregateFunction(1, uniq, UInt64, UInt64) AggregateFunction(1, uniq, UInt64) -1002018 -1002018 +1001943 +1001943 AggregateFunction(uniq, UInt64) 1001943 1001943 diff --git a/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.sql b/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.sql index abdc279c1aea..1cb9ddb91dab 100644 --- a/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.sql +++ b/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.sql @@ -3,8 +3,10 @@ -- `CREATE`, and version 0 is not printed in the type name, so the version 0 pin this test relies on -- does not survive the round trip through the DDL log. --- Version 1 of the `uniq` state keeps 64-bit hashes instead of 32-bit ones, which fixes --- the overflow of the estimate at cardinalities above ten billion (issue #6078). +-- Version 1 of the `uniq` state carries an additional small sample of 64-bit hashes, which fixes +-- the overflow of the estimate at cardinalities above ten billion (issue #6078). The estimate below +-- about two billion still comes from the same 32-bit sample as before, so the values here +-- match the previous versions exactly. -- A fresh state spells its version out in the type name. SELECT toTypeName(uniqState(1)); @@ -25,7 +27,8 @@ FROM (SELECT state FROM remote('127.0.0.2', currentDatabase(), uniq_v1)) SETTINGS prefer_localhost_replica = 0; -- A table with the version pinned to 0 - which is what a table created before the state had a version --- looks like once it is attached on a server that knows about version 1 - keeps the legacy 32-bit format. +-- looks like once it is attached on a server that knows about version 1 - keeps the legacy format, +-- and below the conversion threshold the two versions hold identical values. DROP TABLE IF EXISTS uniq_legacy; CREATE TABLE uniq_legacy (state AggregateFunction(0, uniq, UInt64)) ENGINE = MergeTree ORDER BY tuple(); @@ -43,7 +46,7 @@ SETTINGS prefer_localhost_replica = 0; -- table metadata: the column still has to be read back from its parts as version 0. SELECT type FROM system.columns WHERE database = currentDatabase() AND table = 'uniq_legacy'; --- Merging 64-bit states with legacy 32-bit states must not double-count the common elements: +-- States of the two versions deduplicate against each other when merged: -- the result has to be about 1.5 million (the union of 0..1000000 and 500000..1500000), -- while a failure to deduplicate across the versions would give about 2 million. INSERT INTO uniq_legacy SELECT uniqState(number) FROM numbers(500000, 1000000); @@ -52,7 +55,7 @@ SELECT round(uniqMerge(state), -4) FROM (SELECT state FROM uniq_v1 UNION ALL SEL -- The same in the other order of the arguments of the merge. SELECT round(uniqMerge(state), -4) FROM (SELECT state FROM uniq_legacy UNION ALL SELECT state FROM uniq_v1); --- Below the sampling threshold the 64-bit state is exact. +-- Below the sampling threshold the state is exact (up to occasional 32-bit hash collisions). SELECT uniqMerge(state) FROM (SELECT uniqState(number) AS state FROM numbers(50000)); DROP TABLE uniq_v1; diff --git a/tests/queries/0_stateless/05024_aggregate_state_version_parallel_marshalling.reference b/tests/queries/0_stateless/05024_aggregate_state_version_parallel_marshalling.reference index 068c05d5cbb2..e6c8023cba5c 100644 --- a/tests/queries/0_stateless/05024_aggregate_state_version_parallel_marshalling.reference +++ b/tests/queries/0_stateless/05024_aggregate_state_version_parallel_marshalling.reference @@ -1,3 +1,3 @@ -200358 +200355 100711.5 -1002018 +1001943 diff --git a/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.reference b/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.reference new file mode 100644 index 000000000000..78c78e509c3b --- /dev/null +++ b/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.reference @@ -0,0 +1 @@ +3979938734 diff --git a/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql b/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql new file mode 100644 index 000000000000..3692398a2529 --- /dev/null +++ b/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql @@ -0,0 +1,9 @@ +-- Tags: long, no-fasttest, no-tsan, no-asan, no-msan, no-ubsan +-- Tag no-fasttest, no-*san: aggregates four billion rows, which is too slow there. + +-- Above about two billion elements the estimate of `uniq` switches to the sample +-- of 64-bit hashes carried by version 1 of the state (issue #6078: the 32-bit estimate +-- used to saturate and then overflow into garbage like 18446743978444128518). +-- The result does not depend on the distribution of the rows between the threads. +SELECT uniq(number) FROM numbers_mt(4000000000) +SETTINGS max_rows_to_read = 0, max_bytes_to_read = 0, max_rows_to_read_leaf = 0, max_bytes_to_read_leaf = 0, max_execution_time = 0; From fb0d1b71220e9f792f45ffe6108ea5bcbd084c74 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 20 Aug 2026 11:12:53 +0000 Subject: [PATCH 044/272] Update two missed references and renumber a colliding test `02429_combinators_in_array_reduce` and `02430_initialize_aggregation_with_combinators` print raw `uniq` state bytes produced by `arrayReduce` and `initializeAggregation`, which create a fresh state type and therefore pin state version `1`. Version `1` prepends a flags byte, so both references needed the same update the other raw-state references already got. Also renumber `05023_uniq_state_version_64bit_hashes` to `05027_...`: a test with the same number, `05023_set_index_expression_widened_operand`, landed on `master` in the meantime. --- .../02429_combinators_in_array_reduce.reference | 12 ++++++------ ...initialize_aggregation_with_combinators.reference | 6 +++--- ... 05027_uniq_state_version_64bit_hashes.reference} | 0 ...sql => 05027_uniq_state_version_64bit_hashes.sql} | 0 4 files changed, 9 insertions(+), 9 deletions(-) rename tests/queries/0_stateless/{05023_uniq_state_version_64bit_hashes.reference => 05027_uniq_state_version_64bit_hashes.reference} (100%) rename tests/queries/0_stateless/{05023_uniq_state_version_64bit_hashes.sql => 05027_uniq_state_version_64bit_hashes.sql} (100%) diff --git a/tests/queries/0_stateless/02429_combinators_in_array_reduce.reference b/tests/queries/0_stateless/02429_combinators_in_array_reduce.reference index a0707aa4c01a..cf3e263b9c80 100644 --- a/tests/queries/0_stateless/02429_combinators_in_array_reduce.reference +++ b/tests/queries/0_stateless/02429_combinators_in_array_reduce.reference @@ -1,6 +1,6 @@ -{1:'\0çƒe'} -['\0,ËÂ4çƒe'] -[{1:['\0çƒe']}] -[{1:'\0‰\f¤”µýŸ¿¼'},{1:'\0‰\f¤”µº#¾q'},{1:'\0‰\f*<•º#¾q'}] -[['\0‰\f¤”µýŸ¿¼'],['\0‰\f¤”µº#¾q'],['\0‰\f*<•º#¾q']] -[[{1:['\0‰\f¤”µýŸ¿¼']}],[{1:['\0‰\f¤”µº#¾q']}],[{1:['\0‰\f*<•º#¾q']}]] +{1:'\0\0çƒe'} +['\0\0,ËÂ4çƒe'] +[{1:['\0\0çƒe']}] +[{1:'\0\0‰\f¤”µýŸ¿¼'},{1:'\0\0‰\f¤”µº#¾q'},{1:'\0\0‰\f*<•º#¾q'}] +[['\0\0‰\f¤”µýŸ¿¼'],['\0\0‰\f¤”µº#¾q'],['\0\0‰\f*<•º#¾q']] +[[{1:['\0\0‰\f¤”µýŸ¿¼']}],[{1:['\0\0‰\f¤”µº#¾q']}],[{1:['\0\0‰\f*<•º#¾q']}]] diff --git a/tests/queries/0_stateless/02430_initialize_aggregation_with_combinators.reference b/tests/queries/0_stateless/02430_initialize_aggregation_with_combinators.reference index 2b7ba31b818b..ea704451b29d 100644 --- a/tests/queries/0_stateless/02430_initialize_aggregation_with_combinators.reference +++ b/tests/queries/0_stateless/02430_initialize_aggregation_with_combinators.reference @@ -1,3 +1,3 @@ -{1:'\0çƒe'} -['\0,ËÂ4','\0çƒe'] -[{1:['\0çƒe']}] +{1:'\0\0çƒe'} +['\0\0,ËÂ4','\0\0çƒe'] +[{1:['\0\0çƒe']}] diff --git a/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.reference b/tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.reference similarity index 100% rename from tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.reference rename to tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.reference diff --git a/tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.sql b/tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.sql similarity index 100% rename from tests/queries/0_stateless/05023_uniq_state_version_64bit_hashes.sql rename to tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.sql From 38b4d44ccbedf217eff253740f289a73814136d4 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 20 Aug 2026 13:55:38 +0000 Subject: [PATCH 045/272] Fix the syntax of the parenthesized `JOIN` case in 03913 A subquery inside a parenthesized joined-table list requires an explicit `AS` before its alias, so the sibling-table case was a `SYNTAX_ERROR` (`Fast test` red) instead of exercising the access check. --- .../0_stateless/03913_semi_anti_join_column_access.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 17f6c0909d80..5bc5bc5f4601 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -277,8 +277,8 @@ SELECT * FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN ( - (SELECT 1 AS b) t2 - LEFT SEMI JOIN (SELECT 1 AS c) t3 ON t1.a = t2.b + (SELECT 1 AS b) AS t2 + LEFT SEMI JOIN (SELECT 1 AS c) AS t3 ON t1.a = t2.b ) AS s ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } From 3a5562d2e294250d6aca275bf1c71fff46ea8735 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 20 Aug 2026 13:55:44 +0000 Subject: [PATCH 046/272] Attribute fully qualified `db.table.column` references to the skipped `JOIN` side `tryResolveIdentifierFromJoin` only recorded a denied qualified access when the first identifier part bound as an alias or a table name. A fully qualified `db.table.column` reference to the non-preserved side of a `SEMI`/`ANTI` join therefore produced `UNKNOWN_IDENTIFIER` instead of `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED`, and inside a statically dead `if(false, ...)` branch it was folded away silently. --- src/Analyzer/Resolve/IdentifierResolver.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index c5ace8ab0761..4294f5768227 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1466,8 +1466,14 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I const bool is_table_lookup = identifier_lookup.isTableExpressionLookup(); const bool is_qualified_expr = identifier_lookup.isExpressionLookup() && identifier_lookup.identifier.getPartsSize() > 1; - if ((is_table_lookup || is_qualified_expr) - && qualifierBindsToJoinSubtree(join_tree_node, identifier_lookup.identifier, scope, /*database_qualified=*/false)) + /// A fully qualified `db.table.column` reference names the skipped table just as well as + /// an alias or a bare table name does, so it must be attributed to the skipped side too. + /// Otherwise the reference degrades to `UNKNOWN_IDENTIFIER`, which a statically-dead + /// `if(false, ...)` branch silently folds away instead of reporting the access violation. + const bool binds_qualifier = qualifierBindsToJoinSubtree(join_tree_node, identifier_lookup.identifier, scope, /*database_qualified=*/false) + || (identifier_lookup.identifier.getPartsSize() > 2 + && qualifierBindsToJoinSubtree(join_tree_node, identifier_lookup.identifier, scope, /*database_qualified=*/true)); + if ((is_table_lookup || is_qualified_expr) && binds_qualifier) denied_qualified_access = side; return QueryTreeNodePtr{}; } From ea9d8377582ee6c5d59d6353468bf4d59959468b Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 22 Aug 2026 15:29:44 +0000 Subject: [PATCH 047/272] Fall back to the legacy estimator when the wide sample is incomplete A state serialized in the legacy format (state version `0`) carries no wide set of 64-bit hashes, and its elements were never offered to one. Merging such a state added all of its elements to the 32-bit main sample while leaving the wide sample untouched, so as soon as the version-1 side alone crossed the switch threshold, `size` reported the wide estimate and the legacy half of the merged state was lost entirely - a rolling upgrade merging two disjoint halves of three billion values each would report three billion instead of six. Mark such a state as having an incomplete wide sample: it drops its own wide set, never starts a new one, and propagates the mark through merges and through serialization (bit 1 of the flags byte of state version `1`), so that `size` of anything it is merged into falls back to the 32-bit estimator. The degrading precision of the 32-bit estimate is much better than silently losing a part of the elements. --- contrib/aws-c-http | 2 +- src/AggregateFunctions/UniquesHashSet.h | 54 ++++++++++++--- .../tests/gtest_uniques_hash_set.cpp | 66 +++++++++++++++++-- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/contrib/aws-c-http b/contrib/aws-c-http index 8aefd899fc32..a9745ea9998f 160000 --- a/contrib/aws-c-http +++ b/contrib/aws-c-http @@ -1 +1 @@ -Subproject commit 8aefd899fc3210bfd0e3fd414011a3cb708bf6e4 +Subproject commit a9745ea9998f679cd7456e7d23cc8820e38c97d4 diff --git a/src/AggregateFunctions/UniquesHashSet.h b/src/AggregateFunctions/UniquesHashSet.h index b05a406f918b..e45db6f020c1 100644 --- a/src/AggregateFunctions/UniquesHashSet.h +++ b/src/AggregateFunctions/UniquesHashSet.h @@ -62,8 +62,12 @@ namespace ErrorCodes * * The wide set has to be maintained from the first insertion: the high bits of the hashes * of the already consumed elements cannot be recovered later. In particular, a state written - * in the legacy format (state version 0) carries no wide set, so merging it into a state - * of the new format keeps only the 32-bit precision for the merged-in elements. + * in the legacy format (state version 0) carries no wide set, and its elements were never + * offered to one. Such a state is marked as having an incomplete wide sample: it drops its own + * wide set, never starts a new one, and propagates the mark through merges, so that size() + * of anything it is merged into falls back to the legacy 32-bit estimator. Estimating from + * a wide set that covers only a part of the elements would undercount the rest entirely, + * which is much worse than the degrading precision of the 32-bit estimate. */ /// The maximum degree of buffer size before the values are discarded @@ -156,6 +160,12 @@ private: /// nullptr while no hash has passed the wide thinning (i.e. for most of the states). WideSet * wide = nullptr; + /** Some of the elements of this set were never offered to the wide set, so a wide estimate + * would miss them. This happens when a state serialized in the legacy format (state version 0) + * is read or merged in. The mark is sticky: the missing high bits cannot be recovered. + */ + bool wide_incomplete = false; + #ifdef UNIQUES_HASH_SET_COUNT_COLLISIONS /// For profiling. mutable size_t collisions; @@ -379,6 +389,13 @@ private: wide = nullptr; } + /// The wide set no longer represents all the elements - drop it and never start a new one. + void markWideIncomplete() + { + wide_incomplete = true; + freeWide(); + } + /// Copy the wide set with a table of the given size (the values are reinserted, not memcpy'd). static WideSet * resizeWideSet(const WideSet * old, UInt8 new_size_degree) { @@ -437,6 +454,9 @@ private: void wideInsert(UInt64 x) { + if (unlikely(wide_incomplete)) + return; + /// The caller may have checked the value against a stale (smaller) thinning mask /// for the sake of the hot loop, so the check is repeated here. if (x & ((1ULL << wideSkipDegree()) - 1)) @@ -539,7 +559,7 @@ public: } UniquesHashSet(const UniquesHashSet & rhs) // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - base class is an allocator with stack memory, initialized in alloc() - : m_size(rhs.m_size), skip_degree(rhs.skip_degree), has_zero(rhs.has_zero) + : m_size(rhs.m_size), skip_degree(rhs.skip_degree), has_zero(rhs.has_zero), wide_incomplete(rhs.wide_incomplete) { alloc(rhs.size_degree); memcpy(buf, rhs.buf, buf_size() * sizeof(buf[0])); @@ -568,6 +588,7 @@ public: m_size = rhs.m_size; skip_degree = rhs.skip_degree; has_zero = rhs.has_zero; + wide_incomplete = rhs.wide_incomplete; memcpy(buf, rhs.buf, buf_size() * sizeof(buf[0])); @@ -728,7 +749,9 @@ public: size_t size() const { /** The wide set gives a reliable estimate once it has enough values; - * below that the 32-bit main set is more precise. + * below that the 32-bit main set is more precise. If the wide sample does not cover + * all the elements (see `wide_incomplete`), it is not there at all and the legacy + * estimator is used, because a partial wide estimate would silently lose the rest. */ if (wide && wide->m_size >= UNIQUES_HASH_SET_WIDE_MIN_SIZE_FOR_ESTIMATE) { @@ -766,9 +789,14 @@ public: /// For tests. size_t wideSetSize() const { return wide ? wide->m_size : 0; } + bool isWideIncomplete() const { return wide_incomplete; } void merge(const UniquesHashSet & rhs) { + /// The elements of `rhs` that are missing from its wide set are missing from ours as well. + if (rhs.wide_incomplete) + markWideIncomplete(); + if (rhs.skip_degree > skip_degree) { skip_degree = rhs.skip_degree; @@ -816,7 +844,8 @@ public: /** Two serialization formats exist. * The legacy format (state version 0): skip_degree, then the number of values, then the 32-bit values * of the main set. - * The new format (state version 1): a flags byte (bit 0 - a wide set follows the main set), + * The new format (state version 1): a flags byte (bit 0 - a wide set follows the main set, + * bit 1 - the wide sample is incomplete, see `wide_incomplete`; the two are mutually exclusive), * then the main set in the legacy layout, then, if present, the wide set: its skip_degree, * the number of values, and the 64-bit values. */ @@ -830,7 +859,7 @@ public: if (!use_legacy_format) { - UInt8 flags = wide ? 1 : 0; + UInt8 flags = (wide ? 1 : 0) | (wide_incomplete ? 2 : 0); DB::writeBinaryLittleEndian(flags, wb); } @@ -867,6 +896,7 @@ public: void read(DB::ReadBuffer & rb, bool use_legacy_format) { has_zero = false; + wide_incomplete = false; freeWide(); bool has_wide = false; @@ -874,9 +904,10 @@ public: { UInt8 flags = 0; DB::readBinaryLittleEndian(flags, rb); - if (flags & ~1) + if (flags & ~3) throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "Cannot read UniquesHashSet: unknown flags"); has_wide = flags & 1; + wide_incomplete = flags & 2; } DB::readBinaryLittleEndian(skip_degree, rb); @@ -891,6 +922,13 @@ public: if (m_size > UNIQUES_HASH_MAX_SIZE) throw Poco::Exception("Cannot read UniquesHashSet: too large size_degree."); + /** A non-empty state written in the legacy format carries elements that were never offered + * to a wide set, so no wide estimate of this state (or of anything it is merged into) + * can be trusted. An empty legacy state contributes nothing and does not spoil a merge. + */ + if (use_legacy_format && m_size > 0) + wide_incomplete = true; + free(); UInt8 new_size_degree = m_size <= 1 @@ -972,7 +1010,7 @@ public: { UInt8 flags = 0; DB::readBinaryLittleEndian(flags, rb); - if (flags & ~1) + if (flags & ~3) throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "Cannot read UniquesHashSet: unknown flags"); has_wide = flags & 1; } diff --git a/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp index caa18406ba4e..99500f6ce43c 100644 --- a/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp +++ b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp @@ -237,7 +237,7 @@ TEST(UniquesHashSet, WideSetsMergeAndThinOut) EXPECT_EQ(b2.size(), a.size()); } -TEST(UniquesHashSet, MergingLegacyStateKeepsTheWideSet) +TEST(UniquesHashSet, MergingLegacyStateDropsTheWideSet) { constexpr UInt8 wide_skip_degree = 20; auto main_values = randomSampledValues(50000, 16, 6); @@ -246,12 +246,66 @@ TEST(UniquesHashSet, MergingLegacyStateKeepsTheWideSet) TestSet with_wide = deserialize(craftState(16, main_values, false, wide_skip_degree, wide_values), false); TestSet legacy = deserialize(craftState(16, randomSampledValues(30000, 16, 8), true), true); + EXPECT_TRUE(legacy.isWideIncomplete()); + EXPECT_FALSE(with_wide.isWideIncomplete()); + /// A legacy state carries no wide set: its elements are known by 32 bits of the hash only, - /// so they cannot contribute to the wide set of the merged state, and the estimate - /// keeps only the 32-bit precision for them. - size_t wide_before = with_wide.wideSetSize(); + /// so they cannot contribute to the wide set of the merged state. An estimate from a wide set + /// that covers only a part of the elements would lose the rest, so the merged state drops + /// the wide set altogether and falls back to the 32-bit estimator. with_wide.merge(legacy); - EXPECT_EQ(with_wide.wideSetSize(), wide_before); + EXPECT_TRUE(with_wide.isWideIncomplete()); + EXPECT_EQ(with_wide.wideSetSize(), 0u); + + /// And it stays incomplete: the elements consumed later are not enough to make the sample whole. + for (UInt64 i = 0; i < 1000000; ++i) + with_wide.insert(i); + EXPECT_EQ(with_wide.wideSetSize(), 0u); + + /// The mark travels with the state: a version-1 state whose wide sample is incomplete + /// says so in bit 1 of its flags byte, so a reader does not start estimating from a wide set + /// that it would build after the incomplete elements were already consumed. + std::string serialized = serialize(with_wide, /*use_legacy_format=*/ false); + EXPECT_EQ(serialized[0], 2); + EXPECT_TRUE(deserialize(serialized, /*use_legacy_format=*/ false).isWideIncomplete()); + + /// An empty legacy state contributes nothing, so it does not spoil the wide set. + TestSet clean = deserialize(craftState(16, main_values, false, wide_skip_degree, wide_values), false); + clean.merge(deserialize(craftState(0, {}, true), true)); + EXPECT_FALSE(clean.isWideIncomplete()); + EXPECT_EQ(clean.wideSetSize(), wide_values.size()); +} + +TEST(UniquesHashSet, MergingLegacyStateDoesNotLoseItsElements) +{ + /** The rolling-upgrade case: one shard is new and reports a version-1 state for about + * three billion distinct values, the other is old and reports a version-0 state for another + * disjoint three billion. The merged estimate has to account for both: taking it from + * the wide set - which only ever saw the new shard's half - would report three billion. + * The 32-bit sample saw both halves (that is what the random overlap of the crafted + * values models), so it is the one to use. + */ + constexpr UInt8 wide_skip_degree = 18; + constexpr size_t sampled_main_values = 32950; /// 3e9 distinct values sampled at 2 ^ 16. + constexpr size_t sampled_wide_values = 11444; /// The same 3e9 sampled at 2 ^ 18. + + TestSet versioned = deserialize( + craftState( + 16, randomSampledValues(sampled_main_values, 16, 11), false, + wide_skip_degree, randomSampledValues(sampled_wide_values, wide_skip_degree, 12)), + false); + + TestSet legacy = deserialize(craftState(16, randomSampledValues(sampled_main_values, 16, 13), true), true); + + /// Above the switch threshold the version-1 state alone is estimated from the wide set. + EXPECT_GE(versioned.wideSetSize(), 8192u); + EXPECT_GT(versioned.size(), 2500000000ULL); + EXPECT_LT(versioned.size(), 3500000000ULL); + + versioned.merge(legacy); + + EXPECT_GT(versioned.size(), 5000000000ULL); + EXPECT_LT(versioned.size(), 7000000000ULL); } TEST(UniquesHashSet, InsertPathFeedsTheWideSet) @@ -285,7 +339,7 @@ TEST(UniquesHashSet, RejectsCorruptedStates) { /// Unknown flags. WriteBufferFromOwnString out; - writeBinaryLittleEndian(static_cast(2), out); + writeBinaryLittleEndian(static_cast(4), out); std::string data = out.str(); ReadBufferFromString in(data); TestSet set; From a9e15f7b5f5e559094b039bfd64c2ceb3bc8a249 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 22 Aug 2026 15:38:27 +0000 Subject: [PATCH 048/272] Document the incomplete-wide-sample flag in the `Native` format specification --- docs/reference/interfaces/specs/NativeFormat.mdx | 2 +- docs/reference/interfaces/specs/NativeProtocol.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/interfaces/specs/NativeFormat.mdx b/docs/reference/interfaces/specs/NativeFormat.mdx index 6a02fbda10b3..a0e8efedc8f2 100644 --- a/docs/reference/interfaces/specs/NativeFormat.mdx +++ b/docs/reference/interfaces/specs/NativeFormat.mdx @@ -1059,7 +1059,7 @@ So a `Point` column is decoded exactly as `Tuple(Float64, Float64)` (rendering a Two related types are **not** aliases. They are valid `Native` column types — a client can receive an `AggregateFunction` column from a `-State` combinator or distributed aggregation, for instance — but each carries its own specialized payload that is outside the scope of this page: -- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54493`: version `1` prepends a flags byte to the version-`0` layout and, when bit `0` of the flags is set, appends a small additional sample of full 64-bit hashes (`UInt8 skip_degree`, `VarUInt count`, then the 8-byte values), which keeps the estimate accurate at cardinalities above two billion; a version `0` state carries the 32-bit main sample only. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. +- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54493`: version `1` prepends a flags byte to the version-`0` layout and, when bit `0` of the flags is set, appends a small additional sample of full 64-bit hashes (`UInt8 skip_degree`, `VarUInt count`, then the 8-byte values), which keeps the estimate accurate at cardinalities above two billion; a version `0` state carries the 32-bit main sample only. Bit `1` of the flags says that the 64-bit sample is *incomplete* — the state has absorbed a version `0` state, whose elements were never offered to a 64-bit sample — in which case no 64-bit sample is written and a reader must estimate from the 32-bit main sample alone (and keep saying so in the states it goes on to write); the two bits are therefore mutually exclusive. No other bit is defined, and a reader must reject a state that sets one. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. - `QBit(T, N[, stride])` stores a vector with its bit planes transposed for vector-search workloads; its on-wire stream layout (group-major `FixedString` bit-plane streams, `element_size * (N / stride)` of them with an explicit `stride`) and its binary type encoding (tag `0x36`, or `0x37` `QBitWithStride` when `stride != N`) are documented on the [`QBit` data type page](/reference/data-types/qbit) and in the [binary type encoding](/reference/data-types/data-types-binary-encoding) reference, so a `Native` reader does not have to recover them from the C++ source. diff --git a/docs/reference/interfaces/specs/NativeProtocol.mdx b/docs/reference/interfaces/specs/NativeProtocol.mdx index 01dffb0cad6f..6736dcefbef6 100644 --- a/docs/reference/interfaces/specs/NativeProtocol.mdx +++ b/docs/reference/interfaces/specs/NativeProtocol.mdx @@ -157,7 +157,7 @@ When a feature is active, its fields **must** be present on the wire. The protoc | HTTP_HANDLER_IN_CLIENT_INFO | 54490 | ClientInfo | Adds `http_handler_name` and `http_request_url` (both `String`) to the **HTTP** branch of ClientInfo, immediately after `http_referer`. They carry the name of the matched SQL-defined HTTP handler and the request URL, so that `currentHandler`/`currentRequestURL` and the `http_handler_name`/`http_request_url` `system.query_log` columns stay populated on remote shards for distributed handler queries. Written only when `query_interface = HTTP` (empty strings for a non-handler HTTP query); required once negotiated version ≥ 54490 — omitting it desynchronizes the rest of the Query packet. | | QUANTILE_DETERMINISTIC_SKIP_DEGREE | 54491 | Block (Column) | Bumps the state version of `AggregateFunction(quantileDeterministic, ...)` (and the `quantiles`/`median` spellings of it) from `0` to `1`, which appends a trailing `UInt8 skip_degree` to each serialized state. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states, so an older peer is unaffected. | | STRING_WITH_SIZE_STREAM_SERIALIZATION | 54492 | Block (Column) | `String` columns switch from the per-value length-prefixed layout to a separate stream of cumulative byte offsets (the same offsets layout an `Array` uses), sent as-is: `[UInt64 × num_rows offsets][data blob]`. Gated on the negotiated revision; below 54492 the writer uses the per-value length prefixes. The wire carries no per-column marker for this — both peers derive it from the revision alone. See [String column layout](/reference/interfaces/specs/NativeFormat#string-type). | -| UNIQ_STATE_VERSION_1 | 54493 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`. A version `1` state starts with a flags byte, followed by the main sample in the version-`0` layout; when bit `0` of the flags is set, an additional small sample of full 64-bit hashes follows (`UInt8 skip_degree`, `VarUInt count`, then `count` 8-byte values) — it is thinned out to at most 16384 values and keeps the estimate accurate at cardinalities above two billion, where the 32-bit main sample degrades and used to overflow into garbage. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (the main sample only), so an older peer is unaffected. | +| UNIQ_STATE_VERSION_1 | 54493 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`. A version `1` state starts with a flags byte, followed by the main sample in the version-`0` layout; when bit `0` of the flags is set, an additional small sample of full 64-bit hashes follows (`UInt8 skip_degree`, `VarUInt count`, then `count` 8-byte values) — it is thinned out to at most 16384 values and keeps the estimate accurate at cardinalities above two billion, where the 32-bit main sample degrades and used to overflow into garbage. Bit `1` marks the 64-bit sample as incomplete (the state has absorbed a version `0` state, whose elements the 64-bit sample never saw): no such sample is written, and the estimate comes from the 32-bit main sample alone. The two bits are mutually exclusive and no other bit is defined. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (the main sample only), so an older peer is unaffected. | ## Packet envelope {#packet-envelope} From 4ae13f9f966b06a6c4881a9700cbc17c287a29ff Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Tue, 25 Aug 2026 07:20:53 +0000 Subject: [PATCH 049/272] Never let a `wide_incomplete` state re-acquire a wide set in `merge` Once `wide_incomplete` is set, merging a clean version-1 state whose wide set had a larger `skip_degree` used to allocate an empty wide set that `wideInsert` could never fill, so the state serialized with both flag bits set - a combination the format defines as mutually exclusive. Skip the whole `rhs.wide` path for an incomplete state, reject `flags == 3` in `read` and `skip` defensively, and say so in both format specifications. Addresses the AI review of https://github.com/ClickHouse/ClickHouse/pull/115393 --- .../interfaces/specs/NativeFormat.mdx | 2 +- .../interfaces/specs/NativeProtocol.mdx | 2 +- src/AggregateFunctions/UniquesHashSet.h | 18 ++++++++++++----- .../tests/gtest_uniques_hash_set.cpp | 20 +++++++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/reference/interfaces/specs/NativeFormat.mdx b/docs/reference/interfaces/specs/NativeFormat.mdx index 38e211c5e3ee..a78b19c8b5c1 100644 --- a/docs/reference/interfaces/specs/NativeFormat.mdx +++ b/docs/reference/interfaces/specs/NativeFormat.mdx @@ -1059,7 +1059,7 @@ So a `Point` column is decoded exactly as `Tuple(Float64, Float64)` (rendering a Two related types are **not** aliases. They are valid `Native` column types — a client can receive an `AggregateFunction` column from a `-State` combinator or distributed aggregation, for instance — but each carries its own specialized payload that is outside the scope of this page: -- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54493`: version `1` prepends a flags byte to the version-`0` layout and, when bit `0` of the flags is set, appends a small additional sample of full 64-bit hashes (`UInt8 skip_degree`, `VarUInt count`, then the 8-byte values), which keeps the estimate accurate at cardinalities above two billion; a version `0` state carries the 32-bit main sample only. Bit `1` of the flags says that the 64-bit sample is *incomplete* — the state has absorbed a version `0` state, whose elements were never offered to a 64-bit sample — in which case no 64-bit sample is written and a reader must estimate from the 32-bit main sample alone (and keep saying so in the states it goes on to write); the two bits are therefore mutually exclusive. No other bit is defined, and a reader must reject a state that sets one. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. A wrapper's custom name contains a second copy of its argument type, so the writer must rebuild that copy with the same negotiated aggregate-state version; otherwise its header can announce version `1` for a version-`0` payload. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. +- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54493`: version `1` prepends a flags byte to the version-`0` layout and, when bit `0` of the flags is set, appends a small additional sample of full 64-bit hashes (`UInt8 skip_degree`, `VarUInt count`, then the 8-byte values), which keeps the estimate accurate at cardinalities above two billion; a version `0` state carries the 32-bit main sample only. Bit `1` of the flags says that the 64-bit sample is *incomplete* — the state has absorbed a version `0` state, whose elements were never offered to a 64-bit sample — in which case no 64-bit sample is written and a reader must estimate from the 32-bit main sample alone (and keep saying so in the states it goes on to write); the two bits are therefore mutually exclusive, and a reader must reject a state that sets both. No other bit is defined, and a reader must reject a state that sets one. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. A wrapper's custom name contains a second copy of its argument type, so the writer must rebuild that copy with the same negotiated aggregate-state version; otherwise its header can announce version `1` for a version-`0` payload. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. - `QBit(T, N[, stride])` stores a vector with its bit planes transposed for vector-search workloads; its on-wire stream layout (group-major `FixedString` bit-plane streams, `element_size * (N / stride)` of them with an explicit `stride`) and its binary type encoding (tag `0x36`, or `0x37` `QBitWithStride` when `stride != N`) are documented on the [`QBit` data type page](/reference/data-types/qbit) and in the [binary type encoding](/reference/data-types/data-types-binary-encoding) reference, so a `Native` reader does not have to recover them from the C++ source. diff --git a/docs/reference/interfaces/specs/NativeProtocol.mdx b/docs/reference/interfaces/specs/NativeProtocol.mdx index cbc1178e8239..fc0a9cf43945 100644 --- a/docs/reference/interfaces/specs/NativeProtocol.mdx +++ b/docs/reference/interfaces/specs/NativeProtocol.mdx @@ -159,7 +159,7 @@ When a feature is active, its fields **must** be present on the wire. The protoc | HTTP_HANDLER_IN_CLIENT_INFO | 54490 | ClientInfo | Adds `http_handler_name` and `http_request_url` (both `String`) to the **HTTP** branch of ClientInfo, immediately after `http_referer`. They carry the name of the matched SQL-defined HTTP handler and the request URL, so that `currentHandler`/`currentRequestURL` and the `http_handler_name`/`http_request_url` `system.query_log` columns stay populated on remote shards for distributed handler queries. Written only when `query_interface = HTTP` (empty strings for a non-handler HTTP query); required once negotiated version ≥ 54490 — omitting it desynchronizes the rest of the Query packet. | | QUANTILE_DETERMINISTIC_SKIP_DEGREE | 54491 | Block (Column) | Bumps the state version of `AggregateFunction(quantileDeterministic, ...)` (and the `quantiles`/`median` spellings of it) from `0` to `1`, which appends a trailing `UInt8 skip_degree` to each serialized state. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states, so an older peer is unaffected. | | STRING_WITH_SIZE_STREAM_SERIALIZATION | 54492 | Block (Column) | `String` columns switch from the per-value length-prefixed layout to a separate stream of cumulative byte offsets (the same offsets layout an `Array` uses), sent as-is: `[UInt64 × num_rows offsets][data blob]`. Gated on the negotiated revision; below 54492 the writer uses the per-value length prefixes. The wire carries no per-column marker for this — both peers derive it from the revision alone. See [String column layout](/reference/interfaces/specs/NativeFormat#string-type). | -| UNIQ_STATE_VERSION_1 | 54493 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`. A version `1` state starts with a flags byte, followed by the main sample in the version-`0` layout; when bit `0` of the flags is set, an additional small sample of full 64-bit hashes follows (`UInt8 skip_degree`, `VarUInt count`, then `count` 8-byte values) — it is thinned out to at most 16384 values and keeps the estimate accurate at cardinalities above two billion, where the 32-bit main sample degrades and used to overflow into garbage. Bit `1` marks the 64-bit sample as incomplete (the state has absorbed a version `0` state, whose elements the 64-bit sample never saw): no such sample is written, and the estimate comes from the 32-bit main sample alone. The two bits are mutually exclusive and no other bit is defined. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (the main sample only), so an older peer is unaffected. | +| UNIQ_STATE_VERSION_1 | 54493 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`. A version `1` state starts with a flags byte, followed by the main sample in the version-`0` layout; when bit `0` of the flags is set, an additional small sample of full 64-bit hashes follows (`UInt8 skip_degree`, `VarUInt count`, then `count` 8-byte values) — it is thinned out to at most 16384 values and keeps the estimate accurate at cardinalities above two billion, where the 32-bit main sample degrades and used to overflow into garbage. Bit `1` marks the 64-bit sample as incomplete (the state has absorbed a version `0` state, whose elements the 64-bit sample never saw): no such sample is written, and the estimate comes from the 32-bit main sample alone. The two bits are mutually exclusive and no other bit is defined; a reader must reject a flags byte that sets both or any other bit. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (the main sample only), so an older peer is unaffected. | ## Packet envelope {#packet-envelope} diff --git a/src/AggregateFunctions/UniquesHashSet.h b/src/AggregateFunctions/UniquesHashSet.h index e45db6f020c1..992b1c13902f 100644 --- a/src/AggregateFunctions/UniquesHashSet.h +++ b/src/AggregateFunctions/UniquesHashSet.h @@ -819,7 +819,11 @@ public: } } - if (rhs.wide) + /// An incomplete state must not carry a wide set at all (see `markWideIncomplete`), so the + /// elements of `rhs.wide` are not merged - otherwise an empty wide set would be allocated + /// here (`wideInsert` refuses to fill it) and the state would serialize with both flag bits + /// set, which the format forbids. + if (rhs.wide && !wide_incomplete) { if (rhs.wide->skip_degree > wideSkipDegree()) { @@ -904,8 +908,10 @@ public: { UInt8 flags = 0; DB::readBinaryLittleEndian(flags, rb); - if (flags & ~3) - throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "Cannot read UniquesHashSet: unknown flags"); + if ((flags & ~3) || (flags & 3) == 3) + throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, + "Cannot read UniquesHashSet: invalid flags {}: the defined bits are mutually exclusive and no other bit is allowed", + static_cast(flags)); has_wide = flags & 1; wide_incomplete = flags & 2; } @@ -1010,8 +1016,10 @@ public: { UInt8 flags = 0; DB::readBinaryLittleEndian(flags, rb); - if (flags & ~3) - throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "Cannot read UniquesHashSet: unknown flags"); + if ((flags & ~3) || (flags & 3) == 3) + throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, + "Cannot read UniquesHashSet: invalid flags {}: the defined bits are mutually exclusive and no other bit is allowed", + static_cast(flags)); has_wide = flags & 1; } diff --git a/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp index 99500f6ce43c..9da67b5186b8 100644 --- a/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp +++ b/src/AggregateFunctions/tests/gtest_uniques_hash_set.cpp @@ -274,6 +274,16 @@ TEST(UniquesHashSet, MergingLegacyStateDropsTheWideSet) clean.merge(deserialize(craftState(0, {}, true), true)); EXPECT_FALSE(clean.isWideIncomplete()); EXPECT_EQ(clean.wideSetSize(), wide_values.size()); + + /// Nor does a later merge with a clean wide state resurrect the sample: an incomplete state + /// stays without a wide set even when the other side's thinning is coarser than the initial + /// one (this used to allocate an empty wide set that nothing could fill, making the state + /// serialize with both flag bits - which the format defines as mutually exclusive). + with_wide.merge(clean); + EXPECT_TRUE(with_wide.isWideIncomplete()); + EXPECT_EQ(with_wide.wideSetSize(), 0u); + serialized = serialize(with_wide, /*use_legacy_format=*/ false); + EXPECT_EQ(serialized[0], 2); } TEST(UniquesHashSet, MergingLegacyStateDoesNotLoseItsElements) @@ -345,6 +355,16 @@ TEST(UniquesHashSet, RejectsCorruptedStates) TestSet set; EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); } + { + /// The two defined flag bits are mutually exclusive: a state either carries a wide set + /// or says that its wide sample is incomplete, never both. + WriteBufferFromOwnString out; + writeBinaryLittleEndian(static_cast(3), out); + std::string data = out.str(); + ReadBufferFromString in(data); + TestSet set; + EXPECT_THROW(set.read(in, /*use_legacy_format=*/ false), Poco::Exception); + } { /// Too large wide set skip degree. std::string data = craftState(0, {}, false, 64, {1ULL << 40}); From 73b4125ab361d4d2ce67c50b37477f18558861a8 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Tue, 25 Aug 2026 07:20:53 +0000 Subject: [PATCH 050/272] Preserve the version 0 pin of `uniq` states through normalized queries A query that replays formatted metadata - a `Replicated` database DDL log entry (including the initiator's own re-execution of the entry it wrote), an `ON CLUSTER` secondary, or a restore from a backup - already spells any pinned state version out in the type name, because the initiator normalized it. An unversioned `AggregateFunction(uniq, ...)` in such a query denotes the layout from before the function became versioned, so pinning the current version there re-created old columns as version 1 and made the version-0 data unreadable under `Replicated` databases. Pin the current version only for a definition the user wrote, and pin the empty version to 0 otherwise, the same way `ATTACH` does. Verified with a two-server `Replicated` database: the unversioned column keeps version 0 on both replicas and both read each other's parts, while a fresh column spells version 1 out everywhere. The `no-replicated-database` tag on the test is not needed anymore. Addresses the AI review of https://github.com/ClickHouse/ClickHouse/pull/115393 --- src/Interpreters/InterpreterCreateQuery.cpp | 27 ++++++++++++++----- src/Interpreters/InterpreterCreateQuery.h | 2 +- .../05027_uniq_state_version_64bit_hashes.sql | 5 ---- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/Interpreters/InterpreterCreateQuery.cpp b/src/Interpreters/InterpreterCreateQuery.cpp index 2239ba90c3a4..379e74adc628 100644 --- a/src/Interpreters/InterpreterCreateQuery.cpp +++ b/src/Interpreters/InterpreterCreateQuery.cpp @@ -543,7 +543,7 @@ ASTPtr InterpreterCreateQuery::formatProjections(const ProjectionsDescription & } DataTypePtr InterpreterCreateQuery::getColumnType( - const ASTColumnDeclaration & col_decl, const LoadingStrictnessLevel mode, const bool make_columns_nullable) + const ASTColumnDeclaration & col_decl, const bool make_columns_nullable, const bool pin_current_state_version) { auto col_type = col_decl.getType(); if (!col_type) @@ -554,14 +554,21 @@ DataTypePtr InterpreterCreateQuery::getColumnType( DataTypePtr column_type = DataTypeFactory::instance().get(col_type); - if (LoadingStrictnessLevel::ATTACH <= mode) - setVersionToAggregateFunctions(column_type, true); - else + if (pin_current_state_version) /// Spell the state version the column is going to be written with out in the type, so that /// it gets into the table metadata and the data stays readable when a newer server changes /// the default: an unversioned name in stored metadata denotes the layout from before the - /// function became versioned (the ATTACH branch above pins it to 0). + /// function became versioned (the branch below pins it to 0). pinCurrentStateVersionToAggregateFunctions(column_type); + else + /// The query replays formatted metadata rather than a user-written definition: an `ATTACH` + /// reads the stored `.sql` file, a DDL worker (`ON CLUSTER` / `Replicated` database - + /// including the initiator, which re-executes the entry it wrote to the log) replays a + /// query normalized on the initiator (so a version the initiator pinned is spelled out in + /// the type), and a restore replays the `CREATE` query saved in the backup. In all of these + /// an unversioned name denotes the layout from before the function became versioned, and + /// the data that goes with it is written in that layout - pin it to version 0. + setVersionToAggregateFunctions(column_type, true); if (col_decl.null_modifier) { @@ -612,6 +619,14 @@ ColumnsDescription InterpreterCreateQuery::getColumnsDescription( && !is_restore_from_backup && context_->getSettingsRef()[Setting::data_type_default_nullable]; + /// The current aggregate-function state version is pinned into the type only for a definition + /// the user wrote. A normalized query already spells any pinned version out in the type name, + /// so in it (and in stored metadata, and in a backup) an unversioned name denotes the layout + /// from before the function became versioned and must stay version 0 - see `getColumnType`. + bool pin_current_state_version = mode == LoadingStrictnessLevel::CREATE + && !already_normalized_on_initiator + && !is_restore_from_backup; + for (const auto & ast : columns_ast.children) { const auto & col_decl = ast->as(); @@ -623,7 +638,7 @@ ColumnsDescription InterpreterCreateQuery::getColumnsDescription( } - column_names_and_types.emplace_back(col_decl.name, getColumnType(col_decl, mode, make_columns_nullable)); + column_names_and_types.emplace_back(col_decl.name, getColumnType(col_decl, make_columns_nullable, pin_current_state_version)); /// add column to postprocessing if there is a default_expression specified getDefaultExpressionInfoInto(col_decl, column_names_and_types.back().type, default_expr_info); diff --git a/src/Interpreters/InterpreterCreateQuery.h b/src/Interpreters/InterpreterCreateQuery.h index 376ec835e523..08207d793eec 100644 --- a/src/Interpreters/InterpreterCreateQuery.h +++ b/src/Interpreters/InterpreterCreateQuery.h @@ -73,7 +73,7 @@ public: is_restore_from_backup = is_restore_from_backup_; } - static DataTypePtr getColumnType(const ASTColumnDeclaration & col_decl, LoadingStrictnessLevel mode, bool make_columns_nullable); + static DataTypePtr getColumnType(const ASTColumnDeclaration & col_decl, bool make_columns_nullable, bool pin_current_state_version); /// Obtain information about columns, their types, default values and column comments, /// for case when columns in CREATE query is specified explicitly. diff --git a/tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.sql b/tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.sql index 1cb9ddb91dab..4b0523822050 100644 --- a/tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.sql +++ b/tests/queries/0_stateless/05027_uniq_state_version_64bit_hashes.sql @@ -1,8 +1,3 @@ --- Tags: no-replicated-database --- Tag no-replicated-database: a `Replicated` database re-parses the column type from the formatted --- `CREATE`, and version 0 is not printed in the type name, so the version 0 pin this test relies on --- does not survive the round trip through the DDL log. - -- Version 1 of the `uniq` state carries an additional small sample of 64-bit hashes, which fixes -- the overflow of the estimate at cardinalities above ten billion (issue #6078). The estimate below -- about two billion still comes from the same 32-bit sample as before, so the values here From 348431bac49cc435b348ce45f90b0c8866642581 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Tue, 25 Aug 2026 07:20:53 +0000 Subject: [PATCH 051/272] Update tests for the changed bytes of a fresh `uniqState` A fresh `uniq` state serializes as version 1 (with a flags byte), so the state hashes in `02751_ip_types_aggregate_functions_states` changed, and `test_backward_compatibility/test_functions.py::test_aggregate_states` now allows `uniq` states to differ between the releases as long as the new server still reads the old state to the same value. CI: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=115393&sha=a9e15f7b5f5e559094b039bfd64c2ceb3bc8a249&name_0=PR --- .../test_functions.py | 31 +++++++++++++++++++ ...es_aggregate_functions_states.reference.j2 | 4 +-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_backward_compatibility/test_functions.py b/tests/integration/test_backward_compatibility/test_functions.py index a609c5353af4..bdbd4b72bfc3 100644 --- a/tests/integration/test_backward_compatibility/test_functions.py +++ b/tests/integration/test_backward_compatibility/test_functions.py @@ -106,6 +106,37 @@ def test_aggregate_states(start_cluster): upstream_state = get_aggregate_state_hex(upstream, aggregate_function) if upstream_state != backward_state: + # A fresh `uniq` state on the new server uses state version 1 (a flags byte plus + # a small sample of 64-bit hashes, which fixes the overflow of the estimate at + # cardinalities above ten billion), and the version is spelled out in the state + # type, so the state bytes legitimately differ from the old server's. What must + # hold is that the new server still reads a state written by the old server + # through the unversioned type and finalizes it to the same value. + allowed_changes_if_old_state_is_readable = ["uniq"] + + if aggregate_function in allowed_changes_if_old_state_is_readable: + backward_final = get_final_value_unhex( + backward, aggregate_function, backward_state + ) + upstream_final_from_backward = get_final_value_unhex( + upstream, aggregate_function, backward_state + ) + + if backward_final == upstream_final_from_backward: + logging.info( + "OK %s (the state format changed, but the old state is still readable)", + aggregate_function, + ) + return "passed" + logging.error( + "Failed %s, the old state %s finalizes to %s (backward) != %s (upstream)", + aggregate_function, + backward_state, + backward_final, + upstream_final_from_backward, + ) + return "failed" + allowed_changes_if_result_is_the_same = ["anyHeavy"] if aggregate_function in allowed_changes_if_result_is_the_same: diff --git a/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 b/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 index f3a7043476c7..1e04e2d97a54 100644 --- a/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 +++ b/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 @@ -8,7 +8,7 @@ last_valueState: 15790547582231549775 topKState: 8487398998790115864 groupArrayState: 9875990674330641453 groupUniqArrayState: 15788623094139372318 -uniqState: 13857072740564896081 +uniqState: 8292990047354343272 uniqExactState: 2764760571052675772 uniqCombinedState: 927481020821507998 uniqCombined64State: 11588178464185397904 @@ -24,7 +24,7 @@ last_valueState: 18253481702148601156 topKState: 9849081721007295796 groupArrayState: 15334593495826890008 groupUniqArrayState: 18179202420787216155 -uniqState: 1113473461736161202 +uniqState: 4708630757020163917 uniqExactState: 17291302630176581193 uniqCombinedState: 7689865507370303115 uniqCombined64State: 7689865507370303115 From 56b07b8e0a83bd5710a8bb62c7a5b5f55335c9d5 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 27 Aug 2026 13:25:15 +0000 Subject: [PATCH 052/272] Pin `max_threads` in the four-billion-row `uniq` test The flaky check randomizes `max_threads` down to 1, at which point scanning four billion rows trips `max_estimated_execution_time` (`TOO_SLOW`) long before the test's own limits matter. The result does not depend on the number of threads, so pin it, and disable the execution-time estimate check like the other limits. Co-Authored-By: Claude Fable 5 --- .../05026_uniq_64bit_sample_above_two_billion.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql b/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql index 3692398a2529..c063f7611dfe 100644 --- a/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql +++ b/tests/queries/0_stateless/05026_uniq_64bit_sample_above_two_billion.sql @@ -4,6 +4,8 @@ -- Above about two billion elements the estimate of `uniq` switches to the sample -- of 64-bit hashes carried by version 1 of the state (issue #6078: the 32-bit estimate -- used to saturate and then overflow into garbage like 18446743978444128518). --- The result does not depend on the distribution of the rows between the threads. +-- The result does not depend on the distribution of the rows between the threads, +-- but `max_threads` is pinned because a randomized single-thread run does not finish in time. SELECT uniq(number) FROM numbers_mt(4000000000) -SETTINGS max_rows_to_read = 0, max_bytes_to_read = 0, max_rows_to_read_leaf = 0, max_bytes_to_read_leaf = 0, max_execution_time = 0; +SETTINGS max_rows_to_read = 0, max_bytes_to_read = 0, max_rows_to_read_leaf = 0, max_bytes_to_read_leaf = 0, + max_execution_time = 0, max_estimated_execution_time = 0, max_threads = 16; From 704edd9a1ea45f98fc18d0aafe1754ce63b1314d Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 27 Aug 2026 13:25:28 +0000 Subject: [PATCH 053/272] Thread the state version through the `-ForEach` and `-Map` combinators Both combinators forward `isVersioned` to the nested function, so a column declared as `AggregateFunction(uniqForEach, ...)` or `AggregateFunction(uniqMap, ...)` gets version 1 pinned at DDL time. But `AggregateFunctionForEach::serialize` dropped the version while `deserialize` forwarded it, so the stored payload (written at the nested default, version 0) desynchronized from the later read. `AggregateFunctionMap` dropped the version on both sides, which round-tripped but silently downgraded every per-key `uniq` state to the legacy 32-bit format, losing the wide sample. Forward the version through `serialize` and `deserialize` in both combinators, like every other combinator already does. For the other versioned functions (`sumMap`, `groupBitmap`) this is byte-identical, because their default version equals the latest one. Addresses the review blocker; adds a state round-trip test through `MergeTree` and `remote()` for `uniqForEachState` and `uniqMapState`, including a version-0-pinned legacy table. Co-Authored-By: Claude Fable 5 --- .../Combinators/AggregateFunctionForEach.h | 7 ++- .../Combinators/AggregateFunctionMap.cpp | 11 +++-- ..._version_foreach_map_combinators.reference | 10 ++++ ..._state_version_foreach_map_combinators.sql | 49 +++++++++++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference create mode 100644 tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h b/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h index 2b51aa7e3519..41368db5df14 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h @@ -289,7 +289,7 @@ public: } } - void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional /* version */) const override + void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional version) const override { const AggregateFunctionForEachData & state = data(place); writeBinaryLittleEndian(state.dynamic_array_size, buf); @@ -297,7 +297,10 @@ public: const char * nested_state = state.array_of_aggregate_datas; for (size_t i = 0; i < state.dynamic_array_size; ++i) { - nested_func->serialize(nested_state, buf); + /// `isVersioned` and `getDefaultVersion` forward to the nested function, so the version + /// is in the nested function's terms already. `deserialize` forwards it, so dropping it + /// here would desynchronize the written payload from the later read. + nested_func->serialize(nested_state, buf, version); nested_state += nested_size_of_data; } } diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp b/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp index 14bcbc8ea3b8..e8add654efe1 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp +++ b/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp @@ -262,7 +262,7 @@ public: destroyImpl(place); } - void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional /* version */) const override + void serialize(ConstAggregateDataPtr __restrict place, WriteBuffer & buf, std::optional version) const override { auto & merged_maps = this->data(place).merged_maps; writeVarUInt(merged_maps.size(), buf); @@ -270,11 +270,14 @@ public: for (const auto & elem : merged_maps) { this->data(place).writeKey(elem.first, buf); - nested_func->serialize(elem.second, buf); + /// `isVersioned` and `getDefaultVersion` forward to the nested function, so the version + /// is in the nested function's terms already; dropping it would silently downgrade + /// every per-key state to the format of the nested function's default version. + nested_func->serialize(elem.second, buf, version); } } - void deserialize(AggregateDataPtr __restrict place, ReadBuffer & buf, std::optional /* version */, Arena * arena) const override + void deserialize(AggregateDataPtr __restrict place, ReadBuffer & buf, std::optional version, Arena * arena) const override { auto & merged_maps = this->data(place).merged_maps; UInt64 size = 0; @@ -289,7 +292,7 @@ public: nested_place = arena->alignedAlloc(nested_func->sizeOfData(), nested_func->alignOfData()); nested_func->create(nested_place); merged_maps.emplace(key, nested_place); - nested_func->deserialize(nested_place, buf, std::nullopt, arena); + nested_func->deserialize(nested_place, buf, version, arena); } } diff --git a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference new file mode 100644 index 000000000000..999d09eefece --- /dev/null +++ b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference @@ -0,0 +1,10 @@ +AggregateFunction(uniqForEach, Array(UInt8)) +AggregateFunction(uniqMap, Map(UInt8, UInt8)) +AggregateFunction(1, uniqForEach, Array(UInt64)) +[3,100315] +[3,100315] +AggregateFunction(1, uniqMap, Map(UInt8, UInt64)) +{0:50000,1:50000} +{0:50000,1:50000} +AggregateFunction(uniqForEach, Array(UInt64)) +[3,100315] diff --git a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql new file mode 100644 index 000000000000..431025c85cc2 --- /dev/null +++ b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql @@ -0,0 +1,49 @@ +-- The `-ForEach` and `-Map` combinators forward `isVersioned` to the nested function, so a table +-- column declared as `AggregateFunction(uniqForEach, ...)` / `AggregateFunction(uniqMap, ...)` gets +-- version 1 pinned at DDL time just like plain `uniq` does. The combinators must then thread the +-- version through `serialize` and `deserialize` symmetrically: dropping it on one side +-- desynchronizes the written payload from the later read (the nested `uniq` falls back to its +-- default version 0 on write, but the read expects the version 1 flags byte). + +-- A fresh state does not spell a version out, and so keeps the default version 0 on the wire. +SELECT toTypeName(uniqForEachState([1, 2])); +SELECT toTypeName(uniqMapState(map(1, 2))); + +DROP TABLE IF EXISTS uniq_foreach_v1; +CREATE TABLE uniq_foreach_v1 (state AggregateFunction(uniqForEach, Array(UInt64))) ENGINE = MergeTree ORDER BY tuple(); +SELECT type FROM system.columns WHERE database = currentDatabase() AND table = 'uniq_foreach_v1'; + +INSERT INTO uniq_foreach_v1 SELECT uniqForEachState([number % 3, number]) FROM numbers(100000); + +-- Reading the state locally, and through a distributed query that sends the raw column over the wire, +-- must both round-trip the per-element states. +SELECT uniqForEachMerge(state) FROM uniq_foreach_v1; +SELECT uniqForEachMerge(state) +FROM (SELECT state FROM remote('127.0.0.2', currentDatabase(), uniq_foreach_v1)) +SETTINGS prefer_localhost_replica = 0; + +DROP TABLE uniq_foreach_v1; + +DROP TABLE IF EXISTS uniq_map_v1; +CREATE TABLE uniq_map_v1 (state AggregateFunction(uniqMap, Map(UInt8, UInt64))) ENGINE = MergeTree ORDER BY tuple(); +SELECT type FROM system.columns WHERE database = currentDatabase() AND table = 'uniq_map_v1'; + +INSERT INTO uniq_map_v1 SELECT uniqMapState(map(number % 2, number)) FROM numbers(100000); + +SELECT uniqMapMerge(state) FROM uniq_map_v1; +SELECT uniqMapMerge(state) +FROM (SELECT state FROM remote('127.0.0.2', currentDatabase(), uniq_map_v1)) +SETTINGS prefer_localhost_replica = 0; + +DROP TABLE uniq_map_v1; + +-- A version pinned to 0 - what a table created before `uniq` had a version looks like once it is +-- attached on a server that knows about version 1 - keeps the legacy format under the combinators too. +DROP TABLE IF EXISTS uniq_foreach_legacy; +CREATE TABLE uniq_foreach_legacy (state AggregateFunction(0, uniqForEach, Array(UInt64))) ENGINE = MergeTree ORDER BY tuple(); +SELECT type FROM system.columns WHERE database = currentDatabase() AND table = 'uniq_foreach_legacy'; + +INSERT INTO uniq_foreach_legacy SELECT uniqForEachState([number % 3, number]) FROM numbers(100000); +SELECT uniqForEachMerge(state) FROM uniq_foreach_legacy; + +DROP TABLE uniq_foreach_legacy; From e52ce43a5487659490b603e2a5f14b32365026df Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 28 Aug 2026 05:01:27 +0000 Subject: [PATCH 054/272] Delay the SEMI/ANTI qualified-access denial until the database-qualified fallback has run A skipped-side table or alias whose name equals the database part of a fully qualified `db.table.column` reference set `denied_qualified_access` and threw `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED` before the `database_qualified = true` fallback could resolve the reference from the preserved side (visible with `analyzer_compatibility_prefer_alias_over_subcolumn = 1`). Move the throw after the fallback so the denial is reported only when every remaining preserved-side interpretation has failed, and add regression tests with a skipped-side table named as the current database. --- src/Analyzer/Resolve/IdentifierResolver.cpp | 14 +++++++++----- .../03913_semi_anti_join_column_access.reference | 1 + .../03913_semi_anti_join_column_access.sql | 12 ++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index 4294f5768227..0498c213243b 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1524,11 +1524,6 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I if (!binds_left || binds_right) right_resolved_identifier = try_resolve_identifier_from_join_tree_node(from_join_node.getRightTableExpressionNodeTyped(), join_kind != JoinKind::Right, JoinTableSide::Right); - if (!left_resolved_identifier && !right_resolved_identifier && denied_qualified_access) - { - side_checker.throwIfTableAccessDenied(*denied_qualified_access, *table_expression_node, *scope.scope_node); - } - /** The alias / table-name qualifier can restrict resolution to one side while the identifier is * actually a database-qualified reference (`db.table.column`) to the pruned side (the same token * is the table name of one side and the database name of the other). The database-qualified @@ -1544,6 +1539,15 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I left_resolved_identifier = try_resolve_identifier_from_join_tree_node(from_join_node.getLeftTableExpressionNodeTyped(), join_kind == JoinKind::Right, JoinTableSide::Left); } + /// Report the access violation only after every remaining preserved-side interpretation has + /// failed: a skipped-side alias or table name equal to the database part of a fully qualified + /// `db.table.column` reference must not deny the reference while the database-qualified + /// fallback above can still resolve it from the preserved side. + if (!left_resolved_identifier && !right_resolved_identifier && denied_qualified_access) + { + side_checker.throwIfTableAccessDenied(*denied_qualified_access, *table_expression_node, *scope.scope_node); + } + if (!identifier_lookup.isExpressionLookup()) { if (left_resolved_identifier && right_resolved_identifier) diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 7d8a4124c728..bff9d4729aa5 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -38,4 +38,5 @@ 1 1 42 +1 2 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 5bc5bc5f4601..52a698044673 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -316,6 +316,18 @@ SELECT if(false, semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.s SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l.a, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- Live branch referencing the fully qualified non-preserved side: also denied. SELECT if(true, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- A skipped-side table whose name equals the current database name must not deny a preserved-side +-- fully qualified reference: with analyzer_compatibility_prefer_alias_over_subcolumn the first +-- identifier part binds the skipped right side first, and the denial must wait until the +-- database-qualified fallback has resolved `db.table.column` from the preserved side. +CREATE TABLE {CLICKHOUSE_DATABASE:Identifier}.{CLICKHOUSE_DATABASE:Identifier} (x UInt64) ENGINE = Memory; +INSERT INTO {CLICKHOUSE_DATABASE:Identifier}.{CLICKHOUSE_DATABASE:Identifier} VALUES (7); +SELECT {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l.a FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.{CLICKHOUSE_DATABASE:Identifier} ON true +SETTINGS analyzer_compatibility_prefer_alias_over_subcolumn = 1; +-- A reference that really names the skipped-side table stays denied. +SELECT {CLICKHOUSE_DATABASE:Identifier}.{CLICKHOUSE_DATABASE:Identifier}.x FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.{CLICKHOUSE_DATABASE:Identifier} ON true +SETTINGS analyzer_compatibility_prefer_alias_over_subcolumn = 1; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +DROP TABLE {CLICKHOUSE_DATABASE:Identifier}.{CLICKHOUSE_DATABASE:Identifier}; DROP TABLE semi_anti_qual_l; DROP TABLE semi_anti_qual_r; From 4f23b0cc178382be8d1df0da9e6b1872669ba8be Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 29 Aug 2026 11:53:40 +0000 Subject: [PATCH 055/272] Spell the nested state version out in pass-through combinator state types Address review: a fresh state of a pass-through combinator over `uniq` (`-If`, `-Array`, `-ForEach`, `-Map`, `-ArgMin`/`-ArgMax`, `-OrNull`/`-OrDefault`, `-Resample`, `-Distinct`, and the implicit adaptor for `Nullable` arguments) was created as an unversioned `AggregateFunction(...)` type, so local serialization round trips of the column (`groupArray` over the states, sorting, views) fell back to the legacy version 0 layout and dropped the 64-bit sample, bringing the undercount at cardinalities above ten billion back for these combinators. A new shared helper `getStateTypeWithVersionOf` spells the nested function's current state version out in the combinator's state type, exactly like plain `uniq` does; a combinator over an unversioned function stays unversioned. `-OrNull`/`-OrDefault` also gained the missing `getVersionFromRevision` forwarding. Both `Native` specs document the rule. --- .../interfaces/specs/NativeFormat.mdx | 2 +- .../interfaces/specs/NativeProtocol.mdx | 2 +- .../Combinators/AggregateFunctionArray.h | 5 ++++ ...gregateFunctionCombinatorsArgMinArgMax.cpp | 2 ++ .../Combinators/AggregateFunctionDistinct.h | 5 ++++ .../Combinators/AggregateFunctionForEach.h | 5 ++++ .../Combinators/AggregateFunctionIf.h | 5 ++++ .../Combinators/AggregateFunctionMap.cpp | 5 ++++ .../Combinators/AggregateFunctionNull.h | 5 ++++ .../Combinators/AggregateFunctionOrFill.h | 10 ++++++++ .../Combinators/AggregateFunctionResample.h | 5 ++++ src/AggregateFunctions/IAggregateFunction.cpp | 9 +++++++ src/AggregateFunctions/IAggregateFunction.h | 10 ++++++++ ...es_aggregate_functions_states.reference.j2 | 4 +-- ...function_with_if_return_type_bug.reference | 6 ++--- ..._version_foreach_map_combinators.reference | 16 ++++++++++-- ..._state_version_foreach_map_combinators.sql | 25 ++++++++++++++++++- 17 files changed, 111 insertions(+), 10 deletions(-) diff --git a/docs/reference/interfaces/specs/NativeFormat.mdx b/docs/reference/interfaces/specs/NativeFormat.mdx index a526402bf1ef..a33abde757d0 100644 --- a/docs/reference/interfaces/specs/NativeFormat.mdx +++ b/docs/reference/interfaces/specs/NativeFormat.mdx @@ -1059,7 +1059,7 @@ So a `Point` column is decoded exactly as `Tuple(Float64, Float64)` (rendering a Two related types are **not** aliases. They are valid `Native` column types — a client can receive an `AggregateFunction` column from a `-State` combinator or distributed aggregation, for instance — but each carries its own specialized payload that is outside the scope of this page: -- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54493`: version `1` prepends a flags byte to the version-`0` layout and, when bit `0` of the flags is set, appends a small additional sample of full 64-bit hashes (`UInt8 skip_degree`, `VarUInt count`, then the 8-byte values), which keeps the estimate accurate at cardinalities above two billion; a version `0` state carries the 32-bit main sample only. Bit `1` of the flags says that the 64-bit sample is *incomplete* — the state has absorbed a version `0` state, whose elements were never offered to a 64-bit sample — in which case no 64-bit sample is written and a reader must estimate from the 32-bit main sample alone (and keep saying so in the states it goes on to write); the two bits are therefore mutually exclusive, and a reader must reject a state that sets both. No other bit is defined, and a reader must reject a state that sets one. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. A wrapper's custom name contains a second copy of its argument type, so the writer must rebuild that copy with the same negotiated aggregate-state version; otherwise its header can announce version `1` for a version-`0` payload. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. +- `AggregateFunction(func, ...)` holds an *intermediate* aggregation state (not a finalized value); its binary layout is specific to the aggregate function and version. A versioned aggregate function spells a non-zero state version out as the first argument of the type string — `AggregateFunction(1, quantileDeterministic, UInt64, UInt64)` — while version `0` is not printed, so the type string stays `AggregateFunction(quantileDeterministic, UInt64, UInt64)`. On a negotiated connection (revision above `0`) the writer picks the version from the protocol revision **alone** — never from a version that happens to be pinned on its local type, because version `0` is indistinguishable on the wire from "no version announced" — so the same function can announce different versions on different connections, and a reader must take the version from the type string rather than assume the newest one. At revision `0` there is no peer to derive a version for — the stream is self-describing and read back by whoever wrote it — so a version pinned on the type survives into the stream (and into the announced type string) rather than being downgraded to `0`. `quantileDeterministic` (with the `quantilesDeterministic` and `medianDeterministic` spellings) is versioned from revision `54491`: version `1` appends a trailing `UInt8 skip_degree` to the state, and its prefix is byte-identical to version `0`. Below that revision the writer emits version `0` states, so an older peer is unaffected. `uniq` is versioned from revision `54493`: version `1` prepends a flags byte to the version-`0` layout and, when bit `0` of the flags is set, appends a small additional sample of full 64-bit hashes (`UInt8 skip_degree`, `VarUInt count`, then the 8-byte values), which keeps the estimate accurate at cardinalities above two billion; a version `0` state carries the 32-bit main sample only. Bit `1` of the flags says that the 64-bit sample is *incomplete* — the state has absorbed a version `0` state, whose elements were never offered to a 64-bit sample — in which case no 64-bit sample is written and a reader must estimate from the 32-bit main sample alone (and keep saying so in the states it goes on to write); the two bits are therefore mutually exclusive, and a reader must reject a state that sets both. No other bit is defined, and a reader must reject a state that sets one. All of this applies equally to a state nested inside a container type — `Array`, `Tuple`, `Map`, `Nullable`, `Variant` (where the alternative's spelled-out version changes the type string without changing the discriminator order), or a `Nested` / `SimpleAggregateFunction` wrapper. It also applies under aggregate-function combinators: a pass-through combinator over a versioned function — `uniqIf`, `uniqArray`, `uniqForEach`, `uniqMap`, `uniqArgMin` / `uniqArgMax`, `uniqOrNull` / `uniqOrDefault`, `uniqResample`, and the implicit `Nullable`-argument adaptor — forwards the version to the nested states its payload contains, spells the nested function's current version out in a fresh state's type string exactly like the plain function (`AggregateFunction(1, uniqIf, UInt64, UInt8)`), and derives the version from the protocol revision on a negotiated connection the same way. A wrapper's custom name contains a second copy of its argument type, so the writer must rebuild that copy with the same negotiated aggregate-state version; otherwise its header can announce version `1` for a version-`0` payload. One `Variant` corner case has no wire representation at all: when two alternatives differ only in a spelled-out state version (legal as a column type, since the type strings differ), re-versioning both for the negotiated revision would collapse them into the same alternative, so the sender must fail the transfer rather than announce versions the payload is not written with. `Dynamic` is the deliberate exception to revision-derived versioning: a `Dynamic` column announces its nested types itself — through its own type lists and through the [binary type encoding](/reference/data-types/data-types-binary-encoding), whose explicit version field carries a spelled-out state version (the values of its shared variant embed their type that way even at rest) — and those announcements are part of the column's own data, identical for every peer and every medium, so they are never rewritten for a negotiated revision. A state nested inside a `Dynamic` value is therefore written at the version spelled in its type (which a fresh state carries, since a `-State` query result spells its version out), and at version `0` when the type spells none, on every connection and medium alike. - `QBit(T, N[, stride])` stores a vector with its bit planes transposed for vector-search workloads; its on-wire stream layout (group-major `FixedString` bit-plane streams, `element_size * (N / stride)` of them with an explicit `stride`) and its binary type encoding (tag `0x36`, or `0x37` `QBitWithStride` when `stride != N`) are documented on the [`QBit` data type page](/reference/data-types/qbit) and in the [binary type encoding](/reference/data-types/data-types-binary-encoding) reference, so a `Native` reader does not have to recover them from the C++ source. diff --git a/docs/reference/interfaces/specs/NativeProtocol.mdx b/docs/reference/interfaces/specs/NativeProtocol.mdx index fc0a9cf43945..6ea4634bdf85 100644 --- a/docs/reference/interfaces/specs/NativeProtocol.mdx +++ b/docs/reference/interfaces/specs/NativeProtocol.mdx @@ -159,7 +159,7 @@ When a feature is active, its fields **must** be present on the wire. The protoc | HTTP_HANDLER_IN_CLIENT_INFO | 54490 | ClientInfo | Adds `http_handler_name` and `http_request_url` (both `String`) to the **HTTP** branch of ClientInfo, immediately after `http_referer`. They carry the name of the matched SQL-defined HTTP handler and the request URL, so that `currentHandler`/`currentRequestURL` and the `http_handler_name`/`http_request_url` `system.query_log` columns stay populated on remote shards for distributed handler queries. Written only when `query_interface = HTTP` (empty strings for a non-handler HTTP query); required once negotiated version ≥ 54490 — omitting it desynchronizes the rest of the Query packet. | | QUANTILE_DETERMINISTIC_SKIP_DEGREE | 54491 | Block (Column) | Bumps the state version of `AggregateFunction(quantileDeterministic, ...)` (and the `quantiles`/`median` spellings of it) from `0` to `1`, which appends a trailing `UInt8 skip_degree` to each serialized state. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states, so an older peer is unaffected. | | STRING_WITH_SIZE_STREAM_SERIALIZATION | 54492 | Block (Column) | `String` columns switch from the per-value length-prefixed layout to a separate stream of cumulative byte offsets (the same offsets layout an `Array` uses), sent as-is: `[UInt64 × num_rows offsets][data blob]`. Gated on the negotiated revision; below 54492 the writer uses the per-value length prefixes. The wire carries no per-column marker for this — both peers derive it from the revision alone. See [String column layout](/reference/interfaces/specs/NativeFormat#string-type). | -| UNIQ_STATE_VERSION_1 | 54493 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`. A version `1` state starts with a flags byte, followed by the main sample in the version-`0` layout; when bit `0` of the flags is set, an additional small sample of full 64-bit hashes follows (`UInt8 skip_degree`, `VarUInt count`, then `count` 8-byte values) — it is thinned out to at most 16384 values and keeps the estimate accurate at cardinalities above two billion, where the 32-bit main sample degrades and used to overflow into garbage. Bit `1` marks the 64-bit sample as incomplete (the state has absorbed a version `0` state, whose elements the 64-bit sample never saw): no such sample is written, and the estimate comes from the 32-bit main sample alone. The two bits are mutually exclusive and no other bit is defined; a reader must reject a flags byte that sets both or any other bit. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Below this revision the writer emits version `0` states (the main sample only), so an older peer is unaffected. | +| UNIQ_STATE_VERSION_1 | 54493 | Block (Column) | Bumps the state version of `AggregateFunction(uniq, ...)` from `0` to `1`. A version `1` state starts with a flags byte, followed by the main sample in the version-`0` layout; when bit `0` of the flags is set, an additional small sample of full 64-bit hashes follows (`UInt8 skip_degree`, `VarUInt count`, then `count` 8-byte values) — it is thinned out to at most 16384 values and keeps the estimate accurate at cardinalities above two billion, where the 32-bit main sample degrades and used to overflow into garbage. Bit `1` marks the 64-bit sample as incomplete (the state has absorbed a version `0` state, whose elements the 64-bit sample never saw): no such sample is written, and the estimate comes from the 32-bit main sample alone. The two bits are mutually exclusive and no other bit is defined; a reader must reject a flags byte that sets both or any other bit. Only the state payload of that one aggregate function changes; the column framing is untouched, and every other aggregate function keeps its own version. Pass-through combinators over `uniq` (`uniqIf`, `uniqArray`, `uniqForEach`, `uniqMap`, `uniqArgMin` / `uniqArgMax`, `uniqOrNull` / `uniqOrDefault`, `uniqResample`, and the implicit `Nullable`-argument adaptor) follow the same gate and forward the version to the nested `uniq` states inside their payloads. Below this revision the writer emits version `0` states (the main sample only), so an older peer is unaffected. | ## Packet envelope {#packet-envelope} diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionArray.h b/src/AggregateFunctions/Combinators/AggregateFunctionArray.h index 614bf5aa1470..38dc074b7189 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionArray.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionArray.h @@ -118,6 +118,11 @@ public: return nested_func->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_func); + } + void create(AggregateDataPtr __restrict place) const override { nested_func->create(place); diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionCombinatorsArgMinArgMax.cpp b/src/AggregateFunctions/Combinators/AggregateFunctionCombinatorsArgMinArgMax.cpp index 2dd9f6bfe11f..d1950893bca1 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionCombinatorsArgMinArgMax.cpp +++ b/src/AggregateFunctions/Combinators/AggregateFunctionCombinatorsArgMinArgMax.cpp @@ -138,6 +138,8 @@ public: size_t getDefaultVersion() const override { return nested_function->getDefaultVersion(); } + DataTypePtr getStateType() const override { return this->getStateTypeWithVersionOf(*nested_function); } + bool allocatesMemoryInArena() const override { return nested_function->allocatesMemoryInArena() || singleValueTypeAllocatesMemoryInArena(key_type->getTypeId()); diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionDistinct.h b/src/AggregateFunctions/Combinators/AggregateFunctionDistinct.h index 69c2af03fef1..cedaa4b021ec 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionDistinct.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionDistinct.h @@ -354,6 +354,11 @@ public: return nested_func->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_func); + } + bool canMergeStateFromDifferentVariant(const IAggregateFunction & rhs) const override { /// Distinct state contains a history of unique values and can be merged across diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h b/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h index 41368db5df14..58cd920b80de 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionForEach.h @@ -200,6 +200,11 @@ public: return nested_func->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_func); + } + template void destroyImpl(AggregateDataPtr __restrict place) const noexcept { diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionIf.h b/src/AggregateFunctions/Combinators/AggregateFunctionIf.h index ed6c8e84f8a6..d2e91d2e086c 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionIf.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionIf.h @@ -97,6 +97,11 @@ public: return nested_func->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_func); + } + void create(AggregateDataPtr __restrict place) const override { nested_func->create(place); diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp b/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp index e8add654efe1..cabafe9bf071 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp +++ b/src/AggregateFunctions/Combinators/AggregateFunctionMap.cpp @@ -121,6 +121,11 @@ public: return nested_func->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_func); + } + AggregateFunctionMap(AggregateFunctionPtr nested, const DataTypes & types) : Base(types, nested->getParameters(), std::make_shared(DataTypes{getKeyType(types, nested), nested->getResultType()})) , nested_func(nested) diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionNull.h b/src/AggregateFunctions/Combinators/AggregateFunctionNull.h index 232fe5d8fac6..05306a772f00 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionNull.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionNull.h @@ -302,6 +302,11 @@ public: return nested_function->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_function); + } + AggregateFunctionPtr getNestedFunction() const override { return nested_function; } #if USE_EMBEDDED_COMPILER diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionOrFill.h b/src/AggregateFunctions/Combinators/AggregateFunctionOrFill.h index 3f51d7b5723e..50ff9b216f8c 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionOrFill.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionOrFill.h @@ -80,11 +80,21 @@ public: return nested_function->isVersioned(); } + size_t getVersionFromRevision(size_t revision) const override + { + return nested_function->getVersionFromRevision(revision); + } + size_t getDefaultVersion() const override { return nested_function->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_function); + } + bool isState() const override { return nested_function->isState(); diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionResample.h b/src/AggregateFunctions/Combinators/AggregateFunctionResample.h index b418906d739a..2eae5315f0a5 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionResample.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionResample.h @@ -134,6 +134,11 @@ public: return nested_function->getDefaultVersion(); } + DataTypePtr getStateType() const override + { + return this->getStateTypeWithVersionOf(*nested_function); + } + bool allocatesMemoryInArena() const override { return nested_function->allocatesMemoryInArena(); diff --git a/src/AggregateFunctions/IAggregateFunction.cpp b/src/AggregateFunctions/IAggregateFunction.cpp index 50185b83160f..efbd1d7da7cc 100644 --- a/src/AggregateFunctions/IAggregateFunction.cpp +++ b/src/AggregateFunctions/IAggregateFunction.cpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace DB @@ -17,6 +18,14 @@ DataTypePtr IAggregateFunction::getStateType() const return std::make_shared(shared_from_this(), argument_types, parameters); } +DataTypePtr IAggregateFunction::getStateTypeWithVersionOf(const IAggregateFunction & nested) const +{ + std::optional version; + if (const auto * nested_state = typeid_cast(nested.getStateType().get())) + version = nested_state->getVersionIfExplicit(); + return std::make_shared(shared_from_this(), argument_types, parameters, version); +} + DataTypePtr IAggregateFunction::getNormalizedStateType() const { DataTypes normalized_argument_types; diff --git a/src/AggregateFunctions/IAggregateFunction.h b/src/AggregateFunctions/IAggregateFunction.h index ae4818b5cb1c..56da02e6ba8f 100644 --- a/src/AggregateFunctions/IAggregateFunction.h +++ b/src/AggregateFunctions/IAggregateFunction.h @@ -96,6 +96,16 @@ public: /// Same as the above but normalize state types so that variants with the same binary representation will use the same type. virtual DataTypePtr getNormalizedStateType() const; + /// State type for a pass-through combinator - one that stores nested states inside its own state + /// and forwards `isVersioned` / `getDefaultVersion` to the nested function (`-If`, `-Array`, + /// `-ForEach`, `-Map`, `-ArgMin` / `-ArgMax`, `-OrNull` / `-OrDefault`, `-Resample`, `-Distinct`, + /// and the implicit adaptor for `Nullable` arguments). + /// If the nested function spells its current state version out in its state type, the combinator's + /// state type must spell the same version: a fresh state column otherwise falls back to the legacy + /// default version on local serialization round trips of the column (`groupArray` over the states, + /// sorting, views), losing the information the newer version carries. + DataTypePtr getStateTypeWithVersionOf(const IAggregateFunction & nested) const; + /// Identifies the state representation variant used by this function. /// The default is Aggregation (normal GROUP BY implementation). virtual AggregateFunctionStateVariant getStateVariant() const diff --git a/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 b/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 index 1e04e2d97a54..5bee4f1767e1 100644 --- a/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 +++ b/tests/queries/0_stateless/02751_ip_types_aggregate_functions_states.reference.j2 @@ -72,7 +72,7 @@ last_valueIfState: 15790547582231549775 topKIfState: 8487398998790115864 groupArrayIfState: 9875990674330641453 groupUniqArrayIfState: 15788623094139372318 -uniqIfState: 13857072740564896081 +uniqIfState: 8292990047354343272 uniqExactIfState: 2764760571052675772 uniqCombinedIfState: 927481020821507998 uniqCombined64IfState: 11588178464185397904 @@ -88,7 +88,7 @@ last_valueIfState: 18253481702148601156 topKIfState: 9849081721007295796 groupArrayIfState: 15334593495826890008 groupUniqArrayIfState: 18179202420787216155 -uniqIfState: 1113473461736161202 +uniqIfState: 4708630757020163917 uniqExactIfState: 17291302630176581193 uniqCombinedIfState: 7689865507370303115 uniqCombined64IfState: 7689865507370303115 diff --git a/tests/queries/0_stateless/03210_optimize_rewrite_aggregate_function_with_if_return_type_bug.reference b/tests/queries/0_stateless/03210_optimize_rewrite_aggregate_function_with_if_return_type_bug.reference index 0693acb91683..ffc76950c58a 100644 --- a/tests/queries/0_stateless/03210_optimize_rewrite_aggregate_function_with_if_return_type_bug.reference +++ b/tests/queries/0_stateless/03210_optimize_rewrite_aggregate_function_with_if_return_type_bug.reference @@ -3,7 +3,7 @@ AggregateFunction(count, Nullable(UInt64)) function_name: _CAST, function_type: ordinary, result_type: AggregateFunction(count, Nullable(UInt64)) function_name: countStateIf, function_type: aggregate, result_type: AggregateFunction(count, UInt64) constant_value: \'AggregateFunction(count, Nullable(UInt64))\', constant_value_type: String -AggregateFunction(uniq, Nullable(UInt64)) -010003000000007518F0A8E7830665 -function_name: uniqState, function_type: aggregate, result_type: AggregateFunction(uniq, Nullable(UInt64)) +AggregateFunction(1, uniq, Nullable(UInt64)) +01010003000000007518F0A8E783066512010000000000000000 +function_name: uniqState, function_type: aggregate, result_type: AggregateFunction(1, uniq, Nullable(UInt64)) ---- diff --git a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference index 999d09eefece..133aad065480 100644 --- a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference +++ b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference @@ -1,5 +1,17 @@ -AggregateFunction(uniqForEach, Array(UInt8)) -AggregateFunction(uniqMap, Map(UInt8, UInt8)) +AggregateFunction(1, uniqForEach, Array(UInt8)) +AggregateFunction(1, uniqMap, Map(UInt8, UInt8)) +AggregateFunction(1, uniq, Nullable(UInt8)) +AggregateFunction(1, uniqIf, UInt8, UInt8) +AggregateFunction(1, uniqArray, Array(UInt8)) +AggregateFunction(1, uniqDistinct, UInt8) +AggregateFunction(1, uniqOrNull, UInt8) +AggregateFunction(1, uniqOrDefault, UInt8) +AggregateFunction(1, uniqArgMin, UInt8, UInt8) +AggregateFunction(1, uniqResample(1, 1, 1), UInt8, UInt8) +AggregateFunction(uniqExact, Nullable(UInt8)) +AggregateFunction(sumMap, Map(UInt8, UInt8)) +[3,100315] +{0:50000,1:50000} AggregateFunction(1, uniqForEach, Array(UInt64)) [3,100315] [3,100315] diff --git a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql index 431025c85cc2..97f6c6e7e9e1 100644 --- a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql +++ b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql @@ -5,10 +5,33 @@ -- desynchronizes the written payload from the later read (the nested `uniq` falls back to its -- default version 0 on write, but the read expects the version 1 flags byte). --- A fresh state does not spell a version out, and so keeps the default version 0 on the wire. +-- A fresh state spells the nested function's current state version out in the type name, just like +-- plain `uniqState` does. Otherwise local serialization round trips of the column (`groupArray` +-- over the states, sorting, views) would fall back to the legacy version 0 and lose the 64-bit sample. SELECT toTypeName(uniqForEachState([1, 2])); SELECT toTypeName(uniqMapState(map(1, 2))); +-- The same holds for every pass-through wrapper of a versioned function: the `Nullable` adaptor, +-- `-If`, `-Array`, `-Distinct`, `-OrNull` / `-OrDefault`, `-ArgMin` / `-ArgMax` and `-Resample`. +-- A wrapper of an unversioned function stays unversioned. +SELECT toTypeName(uniqState(toNullable(1))); +SELECT toTypeName(uniqIfState(1, 1)); +SELECT toTypeName(uniqArrayState([1])); +SELECT toTypeName(uniqDistinctState(1)); +SELECT toTypeName(uniqOrNullState(1)); +SELECT toTypeName(uniqOrDefaultState(1)); +SELECT toTypeName(uniqArgMinState(1, 1)); +SELECT toTypeName(uniqResampleState(1, 1, 1)(1, 1)); +SELECT toTypeName(uniqExactState(toNullable(1))); +SELECT toTypeName(sumMapState(map(1, 2))); + +-- A `groupArray` round trip of fresh states serializes each state into an arena and back; +-- the version must survive it, so the merged estimate must match the direct one. +SELECT uniqForEachMerge(arrayJoin(states)) +FROM (SELECT groupArray(state) AS states FROM (SELECT uniqForEachState([number % 3, number]) AS state FROM numbers(100000))); +SELECT uniqMapMerge(arrayJoin(states)) +FROM (SELECT groupArray(state) AS states FROM (SELECT uniqMapState(map(number % 2, number)) AS state FROM numbers(100000))); + DROP TABLE IF EXISTS uniq_foreach_v1; CREATE TABLE uniq_foreach_v1 (state AggregateFunction(uniqForEach, Array(UInt64))) ENGINE = MergeTree ORDER BY tuple(); SELECT type FROM system.columns WHERE database = currentDatabase() AND table = 'uniq_foreach_v1'; From 1cf4b553924d6549b9083bd0558bccd4c7a22ee5 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 30 Aug 2026 15:59:33 +0000 Subject: [PATCH 056/272] Move the SEMI/ANTI compatibility note from the generated `join.mdx` region to the `JOIN` statement doc string The docs check rejects direct edits inside `AUTOGENERATED_START`/`AUTOGENERATED_END` regions; the source of truth for statement reference pages is the registration in `ParserTablesInSelectQuery.cpp`, from which the nightly docs autogeneration regenerates the page. CI: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=96662&sha=e52ce43a5487659490b603e2a5f14b32365026df&name_0=PR&name_1=Docs%20check%20%28Mintlify%29 Co-Authored-By: Claude Fable 5 --- docs/reference/statements/select/join.mdx | 11 ----------- src/Parsers/ParserTablesInSelectQuery.cpp | 11 +++++++++++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/reference/statements/select/join.mdx b/docs/reference/statements/select/join.mdx index ba1fda5b8420..bb957cbe7f01 100644 --- a/docs/reference/statements/select/join.mdx +++ b/docs/reference/statements/select/join.mdx @@ -49,17 +49,6 @@ Additional join types available in ClickHouse are: | `ASOF JOIN`, `LEFT ASOF JOIN` | Joining sequences with a non-exact match. `ASOF JOIN` usage is described below. | | `PASTE JOIN` | Performs a horizontal concatenation of two tables. | - -When using the analyzer, enabling [`semi_join_compatibility`](../../../operations/settings/settings.md#semi_join_compatibility) or [`anti_join_compatibility`](../../../operations/settings/settings.md#anti_join_compatibility) makes the corresponding join expose only its preserved side to expressions resolved after the join result is formed. - -- `LEFT SEMI JOIN` and `LEFT ANTI JOIN` expose only left-side columns. -- `RIGHT SEMI JOIN` and `RIGHT ANTI JOIN` expose only right-side columns. -- This affects clauses such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY`, including qualified wildcards like `t1.*`. -- The `ON` expression of the same `JOIN` can still reference both sides. - -When these settings are disabled, ClickHouse keeps the legacy behavior, where both sides remain accessible and `SELECT *` expands columns from both tables. - - When [join_algorithm](/reference/settings/session-settings/join#join_algorithm) is set to `partial_merge`, `RIGHT JOIN` and `FULL JOIN` are supported only with `ALL` strictness (`SEMI`, `ANTI`, `ANY`, and `ASOF` are not supported). diff --git a/src/Parsers/ParserTablesInSelectQuery.cpp b/src/Parsers/ParserTablesInSelectQuery.cpp index fc69affa2e9c..3e06686b8949 100644 --- a/src/Parsers/ParserTablesInSelectQuery.cpp +++ b/src/Parsers/ParserTablesInSelectQuery.cpp @@ -563,6 +563,17 @@ Additional join types available in ClickHouse are: | `ASOF JOIN`, `LEFT ASOF JOIN` | Joining sequences with a non-exact match. `ASOF JOIN` usage is described below. | | `PASTE JOIN` | Performs a horizontal concatenation of two tables. | + +When using the analyzer, enabling the `semi_join_compatibility` or `anti_join_compatibility` setting makes the corresponding join expose only its preserved side to expressions resolved after the join result is formed. + +- `LEFT SEMI JOIN` and `LEFT ANTI JOIN` expose only left-side columns. +- `RIGHT SEMI JOIN` and `RIGHT ANTI JOIN` expose only right-side columns. +- This affects clauses such as `SELECT`, `PREWHERE`, `WHERE`, `GROUP BY`, `HAVING`, `QUALIFY`, `ORDER BY`, and `LIMIT BY`, including qualified wildcards like `t1.*`. +- The `ON` expression of the same `JOIN` can still reference both sides. + +When these settings are disabled, ClickHouse keeps the legacy behavior, where both sides remain accessible and `SELECT *` expands columns from both tables. + + When [join_algorithm](/reference/settings/session-settings/join#join_algorithm) is set to `partial_merge`, `RIGHT JOIN` and `FULL JOIN` are supported only with `ALL` strictness (`SEMI`, `ANTI`, `ANY`, and `ASOF` are not supported). From 04252d72d391bb0882d61d8164578d43dc8c0ec0 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 30 Aug 2026 16:51:03 +0000 Subject: [PATCH 057/272] Remove the leftover `query_plan_read_in_order_through_spilling_join` settings-history entry The setting no longer exists on master (it was removed after this branch's 2026-08-28 merge), but the history entry survived that merge. A history entry for an unknown setting makes every `compatibility` value below 26.8 throw `UNKNOWN_SETTING`, which failed 33 stateless tests in `Fast test`. CI: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=96662&sha=e52ce43a5487659490b603e2a5f14b32365026df&name_0=PR&name_1=Fast%20test Co-Authored-By: Claude Fable 5 --- src/Core/SettingsChangesHistory.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index cdf0f5c31c57..2f649de1668a 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -152,7 +152,6 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_dictionary_filter_push_down", 0, 1024 * 1024, "New setting enabling Parquet row-group pruning based on dictionary page contents (reader v3). The value is the maximum dictionary page size in bytes for which the optimization applies; 0 (the previous behavior) disables it."}, {"input_format_read_datetime_number_as_raw_value", true, false, "From 26.8, an unquoted number for a `DateTime`/`DateTime64` column in the `JSON` and `Values`/`Quoted` paths (and in `JSONExtract` and typed `JSON`) is a Unix timestamp in seconds, consistent with the `Values` format, `CAST` and `toDateTime64`. Set this to `true` (or `SET compatibility = '26.7'`) to restore the pre-26.8 behavior, where a bare unquoted integer fed to a `DateTime64` column was read as the raw scaled value (ticks). The tab-separated, CSV and other escaped/whole-text formats are not governed by this setting."}, {"query_plan_short_circuit_constant_false_join", false, true, "New setting to short-circuit a JOIN with a constant-false ON condition so the non-contributing side is not read. previous_value=false so `compatibility` with versions before 26.8 restores the pre-existing behavior (no short-circuit)."}, - {"query_plan_read_in_order_through_spilling_join", false, true, "New setting that lets reading in order propagate through a hash join with an automatic spill threshold, by pinning that join in memory so the order it promised is preserved. previous_value=false so `compatibility` with versions before 26.8 restores the pre-existing conservative behavior, where such a join is never used for reading in order but is always free to spill."}, {"query_plan_optimize_lazy_materialization_for_object_storage", false, true, "New setting to use lazy materialization for `ORDER BY ... LIMIT n` queries reading Parquet files from object storage (including Iceberg tables)."}, {"query_plan_optimize_lazy_materialization_for_file", false, true, "New setting to use lazy materialization for `ORDER BY ... LIMIT n` queries reading local Parquet files with the `file` table function and the `File` table engine."}, {"enable_packed_string_keys_in_aggregation", false, true, "New setting to toggle the `PackedStringRef`-based hash table for single-`String`-key GROUP BY. previous_value=false so `compatibility` with versions before 26.8 restores the legacy `StringHashTable`-based method, including its two-level bucketing."}, From 53ec8207537fd066d3952b3d8ac42d93ff4b9224 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Mon, 31 Aug 2026 20:09:49 +0000 Subject: [PATCH 058/272] Update state references for the version-spelling pass-through combinators `uniqState(toNullable(x))` and `uniqStateForEach` now produce version `1` states, whose serialized form carries the flags byte, so the raw state bytes printed by `01380_nullable_state` and `01381_for_each_with_states` gained the extra `00` byte. Both tests pass locally with the updated references. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=115393&sha=4f23b0cc178382be8d1df0da9e6b1872669ba8be&name_0=PR&name_1=Fast%20test PR: https://github.com/ClickHouse/ClickHouse/pull/115393 --- .../01380_nullable_state.reference | 28 +++++++++---------- .../01381_for_each_with_states.reference | 18 ++++++------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/queries/0_stateless/01380_nullable_state.reference b/tests/queries/0_stateless/01380_nullable_state.reference index f87ff0a3f1f4..6cdf95889f7b 100644 --- a/tests/queries/0_stateless/01380_nullable_state.reference +++ b/tests/queries/0_stateless/01380_nullable_state.reference @@ -1,26 +1,26 @@ -0100012CCBC234 +010000012CCBC234 -0100012CCBC234 +010000012CCBC234 --- -0100012CCBC234 +010000012CCBC234 -0100012CCBC234 +010000012CCBC234 --- -0100012CCBC234 +010000012CCBC234 -0100012CCBC234 +010000012CCBC234 --- -0100012CCBC234 +010000012CCBC234 -0100012CCBC234 +010000012CCBC234 --- -0100012CCBC234 +010000012CCBC234 -0100012CCBC234 +010000012CCBC234 --- -0100012CCBC234 +010000012CCBC234 -0100012CCBC234 +010000012CCBC234 --- 1 @@ -58,7 +58,7 @@ \N [0] --- -0100012CCBC234 +010000012CCBC234 --- -0100012CCBC234 +010000012CCBC234 --- diff --git a/tests/queries/0_stateless/01381_for_each_with_states.reference b/tests/queries/0_stateless/01381_for_each_with_states.reference index 3d1732f9d5da..eae068968116 100644 --- a/tests/queries/0_stateless/01381_for_each_with_states.reference +++ b/tests/queries/0_stateless/01381_for_each_with_states.reference @@ -1,16 +1,16 @@ -5B27015C30012CCBC234272C27015C305C30275D -02000000000000000100012CCBC234010000 -['0100012CCBC234','010000'] +5B27015C305C30012CCBC234272C27015C305C305C30275D +0200000000000000010000012CCBC23401000000 +['010000012CCBC234','01000000'] [1,0] -5B27015C30012CCBC234272C27015C305C30275D +5B27015C305C30012CCBC234272C27015C305C305C30275D -5B27015C30012CCBC234272C27015C305C30275D -02000000000000000100012CCBC234010000 +5B27015C305C30012CCBC234272C27015C305C305C30275D +0200000000000000010000012CCBC23401000000 -02000000000000000100012CCBC234010000 -['0100012CCBC234','010000'] +0200000000000000010000012CCBC23401000000 +['010000012CCBC234','01000000'] -['0100012CCBC234','010000'] +['010000012CCBC234','01000000'] [1,0] [1,0] From 5e5b5018080d4bf074737d61603ce90eaa0df59c Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Mon, 7 Sep 2026 20:43:15 +0200 Subject: [PATCH 059/272] Web UI: remember passwordless logins and drop a stale autofilled password The page offers the login to the browser's password manager after each Run, but the Credential Management API cannot express an empty password (`PasswordCredential` rejects it with a `TypeError`) and there is no API to delete a saved login. So after a server/user was saved with a password and later authenticates with an empty one, the browser keeps the stale entry and autofills it on every visit, and the page sends the stale secret with every request. Remember in `localStorage` which server/user logins last succeeded with an empty password (recorded from the credential probe and from a successful query run, forgotten again when a non-empty password succeeds), and drop a browser-autofilled password (`:autofill`) for such a login when it shows up: on the `input` event autofill raises, and at the points that read the field to build a request. A typed password is always kept. Co-Authored-By: Claude Fable 5.1 --- programs/server/play.html | 96 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/programs/server/play.html b/programs/server/play.html index b5a1649aa6ce..63674defc927 100644 --- a/programs/server/play.html +++ b/programs/server/play.html @@ -9384,6 +9384,82 @@ if (main_container && typeof ResizeObserver !== 'undefined') { new ResizeObserver(refreshPinnedLayout).observe(main_container); } +/// Logins whose last successful authentication used an empty password, as `[server, user]` keys (see +/// `passwordlessLoginKey`), persisted in `localStorage`. +/// +/// The browser's password manager is offered the login after each Run (`storeCredentials`), but the +/// Credential Management API cannot express an empty password: `PasswordCredential` rejects it with a +/// `TypeError`, and there is no API to delete a saved login. So once a password was saved for a +/// server/user and that user later authenticates with an empty password (the account was switched to +/// `no_password`, or its password was removed), the browser keeps the stale entry and autofills it on +/// every visit — and the page would send the stale secret with every request. The page cannot fix the +/// browser's store, but it can remember on its own that the last successful login of this server/user +/// was passwordless, and drop the autofilled password the next time one shows up +/// (`dropAutofilledStalePassword`). Only an autofilled value is dropped (recognised by the `:autofill` +/// pseudo-class): a password typed by the user is always kept, and a successful login with it forgets +/// the entry again, so giving the account a password back is a single ordinary login. Picking the saved +/// login from the browser's suggestions while the entry exists drops it too; the authentication then +/// fails visibly and typing the password once resolves it. +const PASSWORDLESS_LOGINS_KEY = 'passwordless_logins'; +const MAX_PASSWORDLESS_LOGINS = 100; + +/// The identity of a login for this purpose: the endpoint as `sameServerAddress` compares it (the +/// root-path spelling and the URL userinfo do not make a different server) and the effective user +/// (`effectiveConnectionUser`, so a user given in the userinfo and the same user typed in the field +/// are one login). +function passwordlessLoginKey(server_address, user) { + let endpoint = server_address; + try { + const u = new URL(server_address, location.href); + endpoint = u.protocol + '//' + u.host + ((u.pathname === '' || u.pathname === '/') ? '/' : u.pathname) + u.search; + } catch (e) { + /// Not a parsable URL: keyed by its raw text, it can only ever match itself. + } + return JSON.stringify([endpoint, effectiveConnectionUser(server_address, user)]); +} + +function loadPasswordlessLogins() { + let parsed = null; + try { parsed = JSON.parse(window.localStorage.getItem(PASSWORDLESS_LOGINS_KEY) ?? '[]'); } catch (e) { /* malformed: start over */ } + return new Set(Array.isArray(parsed) ? parsed.filter(k => typeof k === 'string') : []); +} + +/// Record a successful authentication as `server_address`/`user` with `password`: an empty password +/// remembers the login as passwordless, a non-empty one forgets it. +function rememberLoginPassword(server_address, user, password) { + const key = passwordlessLoginKey(server_address, user); + const logins = loadPasswordlessLogins(); + if (password) { + if (!logins.delete(key)) return; + } else { + if (logins.has(key)) return; + logins.add(key); + /// A `Set` iterates in insertion order, so the oldest entries go first. + while (logins.size > MAX_PASSWORDLESS_LOGINS) logins.delete(logins.values().next().value); + } + window.localStorage.setItem(PASSWORDLESS_LOGINS_KEY, JSON.stringify([...logins])); +} + +/// Whether the browser filled the field itself (its password manager), as opposed to the user typing +/// or the page setting it. Reported by the standard `:autofill` pseudo-class, or its `-webkit-` form in +/// older engines; a browser supporting neither never reports a field as autofilled, which leaves the +/// stale-password handling inert there. +const AUTOFILL_SELECTOR = [':autofill', ':-webkit-autofill'].find(s => CSS.supports(`selector(${s})`)) ?? null; +function isAutofilled(elem) { + return AUTOFILL_SELECTOR !== null && elem.matches(AUTOFILL_SELECTOR); +} + +/// Drop a browser-autofilled password for a login remembered as passwordless (see +/// `PASSWORDLESS_LOGINS_KEY`). Called wherever an autofilled value can first show up or first be +/// sent: from the `input` event autofill raises, and from the points that read the field to build a +/// request, since autofill does not always raise `input`. Returns whether the field was cleared. +function dropAutofilledStalePassword() { + if (!password_elem.value || !isAutofilled(password_elem)) return false; + if (!loadPasswordlessLogins().has(passwordlessLoginKey(url_elem.value, user_elem.value))) return false; + password_elem.value = ''; + return true; +} + let check_credentials_request_num = 0; /// The connection tuple the credential indication was last checked against, or `null` while nothing /// has been checked yet. The commit on leaving the connection surface is licensed by the @@ -9408,11 +9484,18 @@ function connectionNeedsCheck() { function checkCredentials() { ++check_credentials_request_num; const current_check_credentials_request_num = check_credentials_request_num; + /// An autofilled password for a login remembered as passwordless must not be sent, or shown as + /// checked (see `dropAutofilledStalePassword`). + dropAutofilledStalePassword(); /// The indication this request is about to produce belongs to the tuple it is sent with. checked_connection = connectionTuple(); - getServerStatus(url_elem.value, user_elem.value, password_elem.value).then(json => { + const [server_address, user, password] = [url_elem.value, user_elem.value, password_elem.value]; + getServerStatus(server_address, user, password).then(json => { if (current_check_credentials_request_num == check_credentials_request_num) { if (json) { + /// These credentials were accepted: recorded from the values the request was sent with, + /// not the live fields (an autofill raising no `input` could have changed them since). + rememberLoginPassword(server_address, user, password); [user_elem, password_elem].forEach(e => { e.classList.remove('fail'); e.classList.add('ok'); }); document.getElementById('server_info').innerText = `v${json.v}, uptime ${formatUptime(json.t)} `; updateServerInfoVisibility(); @@ -9526,6 +9609,9 @@ let connection_edited = false; /// as the replay intends; they just do not arm a commit. The restored connection's credentials are /// reported once the user re-enters the password, which is a real edit. function onCredentialInput(event) { + /// Browser autofill raises `input` too (when it raises anything): a filled-in password for a login + /// remembered as passwordless is dropped right here, before it can be shown or sent. + dropAutofilledStalePassword(); if (event.isTrusted) { connection_edited = true; } ++connection_generation; resetCredentialsStatus(); @@ -10740,6 +10826,11 @@ async function postImpl(tab, posted_request_num, query, targetResultEl, abortCon const fetch_options = { method: "POST", body: query, signal: abortController.signal, headers: { 'Authorization': 'never' } }; response = await fetch(use_framing ? framing_url : url, fetch_options); + /// Authentication precedes the query, so a successful response means the snapshotted credentials + /// were accepted (see `rememberLoginPassword`). A failure is not recorded either way: it may be + /// the query's own error as well as a rejected login. + if (response.ok) rememberLoginPassword(connection.url, connection.user, connection.password); + /// Detect image results (e.g. `FORMAT PNG`) by the response `Content-Type` rather than by /// parsing the query, so any format that emits an image is rendered inline as a picture. let content_type = response.headers.get('Content-Type') ?? ''; @@ -14812,6 +14903,7 @@ async function postOne() /// reason: these inputs are shared, and re-reading them after the preflight await (as /// `postImpl` used to) would let a tab switch + connection edit during it send this run's /// snapshotted query to the newly entered server or identity. + dropAutofilledStalePassword(); const connection = { url: url_elem.value, user: user_elem.value, password: password_elem.value }; /// Snapshot the selected database at launch too (see the connection above): the panel selection /// is shared, so re-reading it after the preflight await would let a mid-await database change @@ -14932,6 +15024,7 @@ async function postAll() const param_sources = Object.assign({}, tab.params || {}, param_values_at_launch); /// See `postOne`: the connection context and selected database are snapshotted at launch so a tab /// switch + connection/database edit during the preflight await cannot reroute this run. + dropAutofilledStalePassword(); const connection = { url: url_elem.value, user: user_elem.value, password: password_elem.value }; const query_database = selected_database; ++activation_num; @@ -17174,6 +17267,7 @@ function wasmAvailable() { /// Build the request URL the same way `postImpl` does, but for the completion query. function buildCompletionUrl() { + dropAutofilledStalePassword(); const user = user_elem.value; const password = password_elem.value; const server_address = url_elem.value; From 83db48ec1eacaef823ba9999f4b2a1e9655b2ef3 Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:02:56 +0000 Subject: [PATCH 060/272] Bump `google-benchmark` from v1.7.1 to v1.9.5 Update the `google-benchmark` submodule from `v1.7.1` (plus two upstream commits) to the `v1.9.5` release tag. The only build integration change needed is dropping `src/sleep.cc` from the source list in `contrib/google-benchmark-cmake/CMakeLists.txt`: upstream removed `src/sleep.cc` and `src/sleep.h` and replaced `SleepForMilliseconds` with `std::this_thread::sleep_for`. The rest of the source list still matches the `*.cc` files that upstream globs in `src/CMakeLists.txt`. --- contrib/google-benchmark | 2 +- contrib/google-benchmark-cmake/CMakeLists.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/contrib/google-benchmark b/contrib/google-benchmark index 2257fa4d6afb..192ef10025eb 160000 --- a/contrib/google-benchmark +++ b/contrib/google-benchmark @@ -1 +1 @@ -Subproject commit 2257fa4d6afb8e5a2ccd510a70f38fe7fcdf1edf +Subproject commit 192ef10025eb2c4cdd392bc502f0c852196baa48 diff --git a/contrib/google-benchmark-cmake/CMakeLists.txt b/contrib/google-benchmark-cmake/CMakeLists.txt index e499490c0589..29538e91a335 100644 --- a/contrib/google-benchmark-cmake/CMakeLists.txt +++ b/contrib/google-benchmark-cmake/CMakeLists.txt @@ -16,7 +16,6 @@ set (SRCS "${SRC_DIR}/json_reporter.cc" "${SRC_DIR}/perf_counters.cc" "${SRC_DIR}/reporter.cc" - "${SRC_DIR}/sleep.cc" "${SRC_DIR}/statistics.cc" "${SRC_DIR}/string_util.cc" "${SRC_DIR}/sysinfo.cc" From f4e3eb12f00136c91ef105942ec110a2edd757ad Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:35:51 +0000 Subject: [PATCH 061/272] Bump `liburing` from 2.14 to 2.15 Update the `liburing` submodule from `liburing-2.14` to `liburing-2.15`. No changes were needed in `contrib/liburing-cmake`: the set of library sources built by ClickHouse (`queue.c`, `register.c`, `setup.c`, `syscall.c`, `version.c`) is unchanged, and the new public header `src/include/liburing/io_uring/bpf_filter.h` (included by `liburing.h` and `register.c`) already lies under the include directory that `_liburing` exports. The set of feature macros that upstream `configure` emits into `compat.h` is also unchanged, so `compat.h.in` still matches. The version macros in `io_uring_version.h` follow `liburing.spec` automatically and now report 2.15. Co-Authored-By: Claude Opus 5 --- contrib/liburing | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/liburing b/contrib/liburing index e3d35ea59d3b..d41bf9220ec3 160000 --- a/contrib/liburing +++ b/contrib/liburing @@ -1 +1 @@ -Subproject commit e3d35ea59d3ba09075ed4d7751e4bb9049cce64a +Subproject commit d41bf9220ec39277ff235379e9089d9e0fd6c2a5 From a4e1ef85eb4a162c875f883620b8108f3a892307 Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:44:55 +0000 Subject: [PATCH 062/272] Bump `brotli` from v1.0.9 to v1.2.0 Update `brotli` from `v1.0.9` (plus one ClickHouse commit that removed the recursive `research/esaxx` and `research/libdivsufsort` submodules) to the upstream release `v1.2.0`. Upstream `v1.2.0` no longer has a `.gitmodules` file, so the only reason for the `ClickHouse/brotli` fork is gone and the submodule now points directly at `https://github.com/google/brotli`. Build integration changes in `contrib/brotli-cmake/CMakeLists.txt`: - Added the new sources `common/shared_dictionary.c`, `dec/prefix.c`, `dec/static_init.c`, `enc/compound_dictionary.c`, `enc/static_dict_lut.c` and `enc/static_init.c`. The list now matches every `*.c` file under `c/common`, `c/dec` and `c/enc`, which is exactly what upstream's own `CMakeLists.txt` globs. `enc/static_init_lazy.cc` is deliberately left out: it is only used with `BROTLI_STATIC_INIT_LAZY`, and the default is `BROTLI_STATIC_INIT_NONE`. - Removed `BROTLI_BUILD_PORTABLE=1`; the macro no longer exists anywhere in the `brotli` sources. The public C API is additive (shared/compound dictionaries, decoder metadata callbacks, `BrotliEncoderEstimatePeakMemoryUsage`), so `BrotliReadBuffer` and `BrotliWriteBuffer` need no changes. Co-Authored-By: Claude Opus 5 --- .gitmodules | 2 +- contrib/brotli | 2 +- contrib/brotli-cmake/CMakeLists.txt | 63 ++++++++++++++++------------- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/.gitmodules b/.gitmodules index 741e5582d0bf..a002b6cb7ea7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -63,7 +63,7 @@ url = https://github.com/ClickHouse/cppkafka [submodule "contrib/brotli"] path = contrib/brotli - url = https://github.com/ClickHouse/brotli + url = https://github.com/google/brotli [submodule "contrib/h3"] path = contrib/h3 url = https://github.com/ClickHouse/h3 diff --git a/contrib/brotli b/contrib/brotli index d224526f8fa7..028fb5a23661 160000 --- a/contrib/brotli +++ b/contrib/brotli @@ -1 +1 @@ -Subproject commit d224526f8fa736f24b2339e6f421a142de6586c8 +Subproject commit 028fb5a23661f123017c060daa546b55cf4bde29 diff --git a/contrib/brotli-cmake/CMakeLists.txt b/contrib/brotli-cmake/CMakeLists.txt index b89e81ecda19..8c2b41721dc9 100644 --- a/contrib/brotli-cmake/CMakeLists.txt +++ b/contrib/brotli-cmake/CMakeLists.txt @@ -9,35 +9,41 @@ set(BROTLI_SOURCE_DIR "${ClickHouse_SOURCE_DIR}/contrib/brotli/c") set(BROTLI_BINARY_DIR "${ClickHouse_BINARY_DIR}/contrib/brotli/c") set(SRCS - "${BROTLI_SOURCE_DIR}/enc/command.c" - "${BROTLI_SOURCE_DIR}/enc/fast_log.c" - "${BROTLI_SOURCE_DIR}/dec/bit_reader.c" - "${BROTLI_SOURCE_DIR}/dec/state.c" - "${BROTLI_SOURCE_DIR}/dec/huffman.c" - "${BROTLI_SOURCE_DIR}/dec/decode.c" - "${BROTLI_SOURCE_DIR}/enc/encode.c" - "${BROTLI_SOURCE_DIR}/enc/dictionary_hash.c" - "${BROTLI_SOURCE_DIR}/enc/cluster.c" - "${BROTLI_SOURCE_DIR}/enc/entropy_encode.c" - "${BROTLI_SOURCE_DIR}/enc/literal_cost.c" - "${BROTLI_SOURCE_DIR}/enc/compress_fragment_two_pass.c" - "${BROTLI_SOURCE_DIR}/enc/static_dict.c" - "${BROTLI_SOURCE_DIR}/enc/compress_fragment.c" - "${BROTLI_SOURCE_DIR}/enc/block_splitter.c" - "${BROTLI_SOURCE_DIR}/enc/backward_references_hq.c" - "${BROTLI_SOURCE_DIR}/enc/histogram.c" - "${BROTLI_SOURCE_DIR}/enc/brotli_bit_stream.c" - "${BROTLI_SOURCE_DIR}/enc/utf8_util.c" - "${BROTLI_SOURCE_DIR}/enc/encoder_dict.c" - "${BROTLI_SOURCE_DIR}/enc/backward_references.c" - "${BROTLI_SOURCE_DIR}/enc/bit_cost.c" - "${BROTLI_SOURCE_DIR}/enc/metablock.c" - "${BROTLI_SOURCE_DIR}/enc/memory.c" - "${BROTLI_SOURCE_DIR}/common/dictionary.c" - "${BROTLI_SOURCE_DIR}/common/transform.c" - "${BROTLI_SOURCE_DIR}/common/platform.c" - "${BROTLI_SOURCE_DIR}/common/context.c" "${BROTLI_SOURCE_DIR}/common/constants.c" + "${BROTLI_SOURCE_DIR}/common/context.c" + "${BROTLI_SOURCE_DIR}/common/dictionary.c" + "${BROTLI_SOURCE_DIR}/common/platform.c" + "${BROTLI_SOURCE_DIR}/common/shared_dictionary.c" + "${BROTLI_SOURCE_DIR}/common/transform.c" + "${BROTLI_SOURCE_DIR}/dec/bit_reader.c" + "${BROTLI_SOURCE_DIR}/dec/decode.c" + "${BROTLI_SOURCE_DIR}/dec/huffman.c" + "${BROTLI_SOURCE_DIR}/dec/prefix.c" + "${BROTLI_SOURCE_DIR}/dec/state.c" + "${BROTLI_SOURCE_DIR}/dec/static_init.c" + "${BROTLI_SOURCE_DIR}/enc/backward_references.c" + "${BROTLI_SOURCE_DIR}/enc/backward_references_hq.c" + "${BROTLI_SOURCE_DIR}/enc/bit_cost.c" + "${BROTLI_SOURCE_DIR}/enc/block_splitter.c" + "${BROTLI_SOURCE_DIR}/enc/brotli_bit_stream.c" + "${BROTLI_SOURCE_DIR}/enc/cluster.c" + "${BROTLI_SOURCE_DIR}/enc/command.c" + "${BROTLI_SOURCE_DIR}/enc/compound_dictionary.c" + "${BROTLI_SOURCE_DIR}/enc/compress_fragment.c" + "${BROTLI_SOURCE_DIR}/enc/compress_fragment_two_pass.c" + "${BROTLI_SOURCE_DIR}/enc/dictionary_hash.c" + "${BROTLI_SOURCE_DIR}/enc/encode.c" + "${BROTLI_SOURCE_DIR}/enc/encoder_dict.c" + "${BROTLI_SOURCE_DIR}/enc/entropy_encode.c" + "${BROTLI_SOURCE_DIR}/enc/fast_log.c" + "${BROTLI_SOURCE_DIR}/enc/histogram.c" + "${BROTLI_SOURCE_DIR}/enc/literal_cost.c" + "${BROTLI_SOURCE_DIR}/enc/memory.c" + "${BROTLI_SOURCE_DIR}/enc/metablock.c" + "${BROTLI_SOURCE_DIR}/enc/static_dict.c" + "${BROTLI_SOURCE_DIR}/enc/static_dict_lut.c" + "${BROTLI_SOURCE_DIR}/enc/static_init.c" + "${BROTLI_SOURCE_DIR}/enc/utf8_util.c" ) add_library(_brotli ${SRCS}) @@ -45,4 +51,3 @@ add_library(ch_contrib::brotli ALIAS _brotli) target_include_directories(_brotli SYSTEM BEFORE PUBLIC "${BROTLI_SOURCE_DIR}/include") -target_compile_definitions(_brotli PRIVATE BROTLI_BUILD_PORTABLE=1) From 42e77329289c8935e26d3f1a60e3b6f4cd812826 Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:05:15 +0000 Subject: [PATCH 063/272] Bump `flatbuffers` from v24.3.6 to v25.12.19 The submodule pointed at the ClickHouse fork `github.com/ClickHouse/flatbuffers`, but the pinned commit carried no ClickHouse-specific patches, so retarget it to upstream `github.com/google/flatbuffers`. Two source files that upstream added to its library target, `src/file_manager.cpp` and `src/file_name_manager.cpp`, are needed to link, so add them to `FLATBUFFERS_SRCS`. FlatBuffers-generated code contains a `static_assert` on `FLATBUFFERS_VERSION_MAJOR`/`MINOR`/`REVISION` that pins it to the exact release it was produced with, so the bindings Arrow checked in under `contrib/arrow/cpp/src/generated/*_generated.h` no longer compile. Instead of relying on those pre-generated files, build `flatc` and regenerate the Arrow IPC metadata bindings from the Arrow schemas (`contrib/arrow/format/*.fbs` and `arrow/ipc/feather.fbs`) at build time, and put the result first on the include path so `generated/*_generated.h` resolves to them. This mirrors what is already done for the ORC protobuf sources with `protoc`, keeps the FlatBuffers submodule and the Arrow schemas as the single source of truth, and also fixes the silent staleness of those bindings whenever `contrib/arrow` is bumped. The generated output is byte-identical to what a stock upstream `flatc` v25.12.19 build produces from the same schemas. Co-Authored-By: Claude Opus 5 --- .gitmodules | 2 +- contrib/arrow-cmake/CMakeLists.txt | 125 ++++++++++++++++++++++++++++- contrib/flatbuffers | 2 +- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/.gitmodules b/.gitmodules index 741e5582d0bf..b5aca84d8719 100644 --- a/.gitmodules +++ b/.gitmodules @@ -93,7 +93,7 @@ url = https://github.com/ClickHouse/icu [submodule "contrib/flatbuffers"] path = contrib/flatbuffers - url = https://github.com/ClickHouse/flatbuffers + url = https://github.com/google/flatbuffers [submodule "contrib/replxx"] path = contrib/replxx url = https://github.com/ClickHouse/replxx diff --git a/contrib/arrow-cmake/CMakeLists.txt b/contrib/arrow-cmake/CMakeLists.txt index 52dd396142ac..9801c3e1a693 100644 --- a/contrib/arrow-cmake/CMakeLists.txt +++ b/contrib/arrow-cmake/CMakeLists.txt @@ -104,6 +104,8 @@ set(FLATBUFFERS_INCLUDE_DIR "${FLATBUFFERS_SRC_DIR}/include") set(FLATBUFFERS_SRCS ${FLATBUFFERS_SRC_DIR}/src/idl_parser.cpp + ${FLATBUFFERS_SRC_DIR}/src/file_manager.cpp + ${FLATBUFFERS_SRC_DIR}/src/file_name_manager.cpp ${FLATBUFFERS_SRC_DIR}/src/idl_gen_text.cpp ${FLATBUFFERS_SRC_DIR}/src/reflection.cpp ${FLATBUFFERS_SRC_DIR}/src/util.cpp) @@ -114,15 +116,122 @@ add_library(_flatbuffers STATIC ${FLATBUFFERS_SRCS}) target_include_directories(_flatbuffers SYSTEM PUBLIC ${FLATBUFFERS_INCLUDE_DIR}) target_compile_definitions(_flatbuffers PRIVATE -DFLATBUFFERS_LOCALE_INDEPENDENT=0) +# === Arrow IPC metadata headers generated by `flatc` +# +# Arrow ships pre-generated FlatBuffers bindings for its IPC metadata schemas in +# `contrib/arrow/cpp/src/generated/*_generated.h`. Generated FlatBuffers code contains a +# `static_assert` on `FLATBUFFERS_VERSION_MAJOR`/`MINOR`/`REVISION` that pins it to the exact +# FlatBuffers release it was produced with, so those files cannot be compiled against any other +# version of the library, and they also silently go stale when `contrib/arrow` is bumped without +# Arrow regenerating them. +# +# Generate the bindings ourselves from the Arrow schemas instead, and put the result first on the +# include path so `generated/*_generated.h` resolves to the freshly generated copies. This keeps +# the FlatBuffers submodule and the Arrow schemas as the single source of truth, and mirrors what +# is done for the ORC protobuf sources with `protoc` above. +# +# The `flatc` flags come from Arrow's own `cpp/build-support/update-flatbuffers.sh`, minus +# `--scoped-enums`: the bindings Arrow checked in are unscoped and its C++ code spells the +# enumerators accordingly (`Type_Int` rather than `Type::Int`). + +set(FLATC_SRCS + ${FLATBUFFERS_SRC_DIR}/src/annotated_binary_text_gen.cpp + ${FLATBUFFERS_SRC_DIR}/src/bfbs_gen_lua.cpp + ${FLATBUFFERS_SRC_DIR}/src/bfbs_gen_nim.cpp + ${FLATBUFFERS_SRC_DIR}/src/binary_annotator.cpp + ${FLATBUFFERS_SRC_DIR}/src/code_generators.cpp + ${FLATBUFFERS_SRC_DIR}/src/flatc.cpp + ${FLATBUFFERS_SRC_DIR}/src/flatc_main.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_binary.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_cpp.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_csharp.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_dart.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_fbs.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_go.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_grpc.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_java.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_json_schema.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_kotlin.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_kotlin_kmp.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_lobster.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_php.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_python.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_rust.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_swift.cpp + ${FLATBUFFERS_SRC_DIR}/src/idl_gen_ts.cpp + ${FLATBUFFERS_SRC_DIR}/include/codegen/python.cc + ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/cpp_generator.cc + ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/go_generator.cc + ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/java_generator.cc + ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/python_generator.cc + ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/swift_generator.cc + ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/ts_generator.cc) + +add_executable(flatc ${FLATC_SRCS}) +target_link_libraries(flatc PRIVATE _flatbuffers) +target_include_directories(flatc SYSTEM PRIVATE + ${FLATBUFFERS_INCLUDE_DIR} + ${FLATBUFFERS_SRC_DIR} + ${FLATBUFFERS_SRC_DIR}/grpc) +target_compile_definitions(flatc PRIVATE -DFLATBUFFERS_LOCALE_INDEPENDENT=0) + +add_native_target (flatc) +if (NOT CMAKE_HOST_SYSTEM_NAME STREQUAL CMAKE_SYSTEM_NAME OR NOT CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL CMAKE_SYSTEM_PROCESSOR OR USE_MUSL) + set_target_properties (flatc PROPERTIES IMPORTED_LOCATION "${PROJECT_BINARY_DIR}/native/contrib/arrow-cmake/flatc") + set(FLATC_EXECUTABLE "${PROJECT_BINARY_DIR}/native/contrib/arrow-cmake/flatc") +else () + set(FLATC_EXECUTABLE $) +endif() + +set(ARROW_FORMAT_DIR "${ClickHouse_SOURCE_DIR}/contrib/arrow/format") +# The bindings live in a "generated" subdirectory of the include directory, because that is how +# Arrow's sources spell them: #include "generated/Message_generated.h". +set(ARROW_IPC_GENERATED_INCLUDE_DIR "${FLATBUFFERS_BINARY_DIR}") +set(ARROW_IPC_GENERATED_DIR "${ARROW_IPC_GENERATED_INCLUDE_DIR}/generated") + +set(ARROW_FBS + ${ARROW_FORMAT_DIR}/File.fbs + ${ARROW_FORMAT_DIR}/Message.fbs + ${ARROW_FORMAT_DIR}/Schema.fbs + ${ARROW_FORMAT_DIR}/SparseTensor.fbs + ${ARROW_FORMAT_DIR}/Tensor.fbs + ${ARROW_SRC_DIR}/arrow/ipc/feather.fbs) + +set(ARROW_IPC_GENERATED_HEADERS + ${ARROW_IPC_GENERATED_DIR}/File_generated.h + ${ARROW_IPC_GENERATED_DIR}/Message_generated.h + ${ARROW_IPC_GENERATED_DIR}/Schema_generated.h + ${ARROW_IPC_GENERATED_DIR}/SparseTensor_generated.h + ${ARROW_IPC_GENERATED_DIR}/Tensor_generated.h + ${ARROW_IPC_GENERATED_DIR}/feather_generated.h) + +add_custom_command(OUTPUT ${ARROW_IPC_GENERATED_HEADERS} + COMMAND ${CMAKE_COMMAND} -E make_directory "${ARROW_IPC_GENERATED_DIR}" + COMMAND ${FLATC_EXECUTABLE} + --cpp + --cpp-std c++11 + # The Arrow schemas use camelCase field names, which `flatc` warns about on every run. + # We cannot rename them, so do not spam the build log. + --no-warnings + -o "${ARROW_IPC_GENERATED_DIR}" + ${ARROW_FBS} + # Depend on `flatc` itself as well, otherwise stale bindings are compiled after a + # FlatBuffers bump, and they fail the version `static_assert` in the generated code. + DEPENDS ${ARROW_FBS} ${FLATC_EXECUTABLE}) + +add_custom_target(_arrow_ipc_generated DEPENDS ${ARROW_IPC_GENERATED_HEADERS}) + # Interface target exposing only the FlatBuffers-generated Arrow IPC metadata headers # (Message_generated.h, Schema_generated.h, File_generated.h) plus the FlatBuffers library. # The native Arrow IPC reader/writer (src/Processors/Formats/Impl/ArrowIPC) links this instead # of the Apache Arrow C++ runtime: it parses/builds the IPC metadata with FlatBuffers and decodes # the buffers directly to/from ClickHouse columns. The include base ".../cpp/src" lets the code -# use while the generated headers' own relative includes resolve. +# use while the generated headers' own relative includes resolve; +# the generated directory comes first so it shadows the pre-generated copies shipped by Arrow. add_library(_arrow_ipc_metadata INTERFACE) -target_include_directories(_arrow_ipc_metadata SYSTEM INTERFACE "${ClickHouse_SOURCE_DIR}/contrib/arrow/cpp/src") +target_include_directories(_arrow_ipc_metadata SYSTEM INTERFACE "${ARROW_IPC_GENERATED_INCLUDE_DIR}" "${ARROW_SRC_DIR}") target_link_libraries(_arrow_ipc_metadata INTERFACE _flatbuffers) +add_dependencies(_arrow_ipc_metadata _arrow_ipc_generated) add_library(ch_contrib::arrow_ipc_metadata ALIAS _arrow_ipc_metadata) @@ -583,6 +692,13 @@ endif() target_include_directories(_arrow SYSTEM BEFORE PUBLIC ${ARROW_SRC_DIR}) target_include_directories(_arrow SYSTEM BEFORE PUBLIC "${ClickHouse_SOURCE_DIR}/contrib/arrow-cmake/cpp/src") +# Before ${ARROW_SRC_DIR} so that "generated/*_generated.h" resolves to the bindings we generate +# from the Arrow schemas with our own `flatc`, not to the pre-generated ones Arrow checked in, +# which are pinned by a `static_assert` to the FlatBuffers version they were produced with. +# The other files under "generated/" (the Thrift bindings for Parquet) are not shadowed and keep +# resolving to ${ARROW_SRC_DIR}. +target_include_directories(_arrow SYSTEM BEFORE PUBLIC ${ARROW_IPC_GENERATED_INCLUDE_DIR}) +add_dependencies(_arrow _arrow_ipc_generated) target_include_directories(_arrow SYSTEM PRIVATE ${HDFS_INCLUDE_DIR}) @@ -666,8 +782,13 @@ add_library(_parquet ${PARQUET_SRCS}) add_library(ch_contrib::parquet ALIAS _parquet) target_include_directories(_parquet SYSTEM BEFORE PUBLIC + # Must stay ahead of the Arrow source directory, see the comment on `_arrow` above. It is + # repeated here because consumers linking both `_parquet` and `_arrow` inherit the include + # order of whichever target comes first in their link interface. + "${ARROW_IPC_GENERATED_INCLUDE_DIR}" "${ClickHouse_SOURCE_DIR}/contrib/arrow/cpp/src" "${CMAKE_CURRENT_SOURCE_DIR}/cpp/src") +add_dependencies(_parquet _arrow_ipc_generated) target_link_libraries(_parquet PUBLIC _arrow diff --git a/contrib/flatbuffers b/contrib/flatbuffers index 0bed8cd4a001..7e163021e59c 160000 --- a/contrib/flatbuffers +++ b/contrib/flatbuffers @@ -1 +1 @@ -Subproject commit 0bed8cd4a001850de9591563df99b435349ba05e +Subproject commit 7e163021e59cca4f8e1e35a7c828b5c6b7915953 From 484a7f7f6f1dda774f81ab03347a07e24720fd78 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Wed, 9 Sep 2026 12:55:30 +0000 Subject: [PATCH 064/272] Spell the state version out in the `-Tuple` combinator state type `AggregateFunctionTuple` is a pass-through combinator - it keeps one nested state per tuple element inside its own state, reports `isVersioned` from the nested functions and forwards the version to every nested state in `serialize` and `deserialize` - but it did not override `getStateType`, so a fresh `uniqTupleState` / `quantileDeterministicTupleState` column was created as an unversioned `AggregateFunction(...Tuple, ...)` type and therefore at version 0. Local carriers such as `groupArray` over the states, sorting and views then wrote the nested states in the legacy layout, dropping the `uniq` wide sample and the `quantileDeterministic` `skip_degree` this pull request is meant to preserve - the same defect that was already fixed for `-If`, `-Array`, `-ForEach`, `-Map` and the other wrappers. `getStateType` now spells out the same maximum over the nested functions that `getDefaultVersion` takes, computed from the versions the nested functions spell out in their own state types, so a `-Tuple` of unversioned functions stays byte-identical. Addresses the review comment on `src/AggregateFunctions/IAggregateFunction.h`. Co-Authored-By: Claude Opus 5 (1M context) --- .../Combinators/AggregateFunctionTuple.cpp | 21 ++++++++++++ .../Combinators/AggregateFunctionTuple.h | 1 + ...q_state_version_tuple_combinator.reference | 9 +++++ ...53_uniq_state_version_tuple_combinator.sql | 34 +++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.reference create mode 100644 tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.sql diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp b/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp index 4001af145fa1..5118d4471f10 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp +++ b/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp @@ -90,6 +90,27 @@ size_t AggregateFunctionTuple::getVersionFromRevision(size_t revision) const return version; } +/// `-Tuple` is a pass-through combinator: it stores the nested states inside its own state and +/// forwards the version to every one of them in `serialize` / `deserialize`. So, like the other +/// pass-through combinators, its state type has to spell the version out - otherwise a fresh +/// state column is created at the default version and every local round trip of the column +/// (`groupArray` over the states, sorting, views) writes the nested states in the legacy layout. +/// The version is the same maximum over the nested functions that `getDefaultVersion` takes, +/// but computed from the versions the nested functions spell out in their own state types. +DataTypePtr AggregateFunctionTuple::getStateType() const +{ + std::optional version; + for (const auto & func : nested_functions) + { + const auto * nested_state = typeid_cast(func->getStateType().get()); + if (!nested_state) + continue; + if (auto nested_version = nested_state->getVersionIfExplicit()) + version = std::max(version.value_or(0), *nested_version); + } + return std::make_shared(shared_from_this(), argument_types, parameters, version); +} + void AggregateFunctionTuple::create(AggregateDataPtr __restrict place) const { size_t i = 0; diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionTuple.h b/src/AggregateFunctions/Combinators/AggregateFunctionTuple.h index 4cefe838a21b..15ad931df4ba 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionTuple.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionTuple.h @@ -60,6 +60,7 @@ public: bool isVersioned() const override; size_t getDefaultVersion() const override; size_t getVersionFromRevision(size_t revision) const override; + DataTypePtr getStateType() const override; size_t sizeOfData() const override { return total_state_size; } size_t alignOfData() const override { return max_state_align; } diff --git a/tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.reference b/tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.reference new file mode 100644 index 000000000000..002fdcb64056 --- /dev/null +++ b/tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.reference @@ -0,0 +1,9 @@ +AggregateFunction(1, uniqTuple, Tuple(UInt8, UInt8)) +AggregateFunction(1, uniqTuple, Tuple(Nullable(UInt8), UInt8)) +AggregateFunction(sumTuple, Tuple(UInt8, UInt8)) +AggregateFunction(avgTuple, Tuple(UInt8, UInt8)) +(3,100315) +(3,100315) +AggregateFunction(1, uniqTuple, Tuple(UInt64, UInt64)) +(3,100315) +(3,100315) diff --git a/tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.sql b/tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.sql new file mode 100644 index 000000000000..8ad7f6202920 --- /dev/null +++ b/tests/queries/0_stateless/05153_uniq_state_version_tuple_combinator.sql @@ -0,0 +1,34 @@ +-- The `-Tuple` combinator is a pass-through combinator: it keeps one nested state per tuple element +-- inside its own state and forwards the version to every nested state in `serialize` / `deserialize`. +-- Its state type therefore has to spell the version out, exactly like the other pass-through +-- combinators. Otherwise a fresh state column is created at the default version and every local +-- serialization round trip of the column (`groupArray` over the states, sorting, views) writes the +-- nested `uniq` states in the legacy version 0 layout and drops the 64-bit sample. + +SELECT toTypeName(uniqTupleState((1, 2))); +SELECT toTypeName(uniqTupleState((toNullable(1), 2))); + +-- A `-Tuple` of an unversioned function stays unversioned. +SELECT toTypeName(sumTupleState((1, 2))); +SELECT toTypeName(avgTupleState((1, 2))); + +-- A `groupArray` round trip of fresh states serializes each state into an arena and back; +-- the version must survive it, so the merged estimate must match the direct one. +SELECT uniqTupleMerge(arrayJoin(states)) +FROM (SELECT groupArray(state) AS states FROM (SELECT uniqTupleState((number % 3, number)) AS state FROM numbers(100000))); +SELECT uniqTupleMerge(state) FROM (SELECT uniqTupleState((number % 3, number)) AS state FROM numbers(100000)); + +DROP TABLE IF EXISTS uniq_tuple_v1; +CREATE TABLE uniq_tuple_v1 (state AggregateFunction(uniqTuple, Tuple(UInt64, UInt64))) ENGINE = MergeTree ORDER BY tuple(); +SELECT type FROM system.columns WHERE database = currentDatabase() AND table = 'uniq_tuple_v1'; + +INSERT INTO uniq_tuple_v1 SELECT uniqTupleState((toUInt64(number % 3), number)) FROM numbers(100000); + +-- Reading the state locally, and through a distributed query that sends the raw column over the wire, +-- must both round-trip the per-element states. +SELECT uniqTupleMerge(state) FROM uniq_tuple_v1; +SELECT uniqTupleMerge(state) +FROM (SELECT state FROM remote('127.0.0.2', currentDatabase(), uniq_tuple_v1)) +SETTINGS prefer_localhost_replica = 0; + +DROP TABLE uniq_tuple_v1; From 97989e2844ba78a6a51729c207646b203ef496ee Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Wed, 9 Sep 2026 13:01:35 +0000 Subject: [PATCH 065/272] Fix a use-after-free in `getStateTypeWithVersionOf` `getStateType` returns a fresh `DataTypePtr` by value, so `typeid_cast(nested.getStateType().get())` leaves the raw pointer dangling: the temporary `shared_ptr` is the only owner and is destroyed at the end of the full expression, before `getVersionIfExplicit` is called on it. ASan caught it in `haveSameStateRepresentationImpl` on a distributed plan: ERROR: AddressSanitizer: heap-use-after-free READ of size 8 ... thread T998 (TCPHandler) #0 DB::DataTypeAggregateFunction::getVersionIfExplicit() const #1 DB::IAggregateFunction::getStateTypeWithVersionOf(...) const #2 DB::IAggregateFunction::haveSameStateRepresentationImpl(...) const #3 DB::haveCompatibleColumnStructure(...) #6 DB::addConvertingActions(...) #7 DB::createLocalPlan(...) https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=115393&sha=53ec8207537fd066d3952b3d8ac42d93ff4b9224&name_0=PR&name_1=Stateless%20tests%20%28amd_asan_ubsan%2C%20distributed%20plan%2C%20parallel%2C%20selected%20tests%29 https://github.com/ClickHouse/ClickHouse/pull/115393 The state type is now held in a named variable in both `IAggregateFunction::getStateTypeWithVersionOf` and the `-Tuple` combinator's `getStateType`, which had the same pattern. Co-Authored-By: Claude Opus 5 (1M context) --- .../Combinators/AggregateFunctionTuple.cpp | 5 ++++- src/AggregateFunctions/IAggregateFunction.cpp | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp b/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp index 5118d4471f10..16b21e10fe91 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp +++ b/src/AggregateFunctions/Combinators/AggregateFunctionTuple.cpp @@ -102,7 +102,10 @@ DataTypePtr AggregateFunctionTuple::getStateType() const std::optional version; for (const auto & func : nested_functions) { - const auto * nested_state = typeid_cast(func->getStateType().get()); + /// `getStateType` returns a fresh `DataTypePtr` by value, so it has to be held in a named + /// variable - a temporary would be destroyed at the end of the full expression. + const DataTypePtr nested_state_type = func->getStateType(); + const auto * nested_state = typeid_cast(nested_state_type.get()); if (!nested_state) continue; if (auto nested_version = nested_state->getVersionIfExplicit()) diff --git a/src/AggregateFunctions/IAggregateFunction.cpp b/src/AggregateFunctions/IAggregateFunction.cpp index efbd1d7da7cc..b4f74a699c87 100644 --- a/src/AggregateFunctions/IAggregateFunction.cpp +++ b/src/AggregateFunctions/IAggregateFunction.cpp @@ -21,7 +21,11 @@ DataTypePtr IAggregateFunction::getStateType() const DataTypePtr IAggregateFunction::getStateTypeWithVersionOf(const IAggregateFunction & nested) const { std::optional version; - if (const auto * nested_state = typeid_cast(nested.getStateType().get())) + /// The state type has to be held in a named variable: `getStateType` returns a fresh + /// `DataTypePtr` by value, and a temporary would be destroyed at the end of the full + /// expression, leaving `nested_state` dangling. + const DataTypePtr nested_state_type = nested.getStateType(); + if (const auto * nested_state = typeid_cast(nested_state_type.get())) version = nested_state->getVersionIfExplicit(); return std::make_shared(shared_from_this(), argument_types, parameters, version); } From d2f6ef11f4365d3b9afb71e9e707ce9f213700c8 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 10 Sep 2026 14:47:08 +0000 Subject: [PATCH 066/272] Resolve a column matcher in a filter against the table the filter applies to `additional_table_filters = {'t': 'not ignore(*)'}` threw `Unqualified matcher * cannot be resolved. There are no table sources`, and so did `COLUMNS('.*')`, a qualified `t.*`, `additional_result_filter` and a row policy that uses a matcher. The identical predicate in `WHERE` works, as does the filter under the old analyzer, and `SELECT ... WHERE p` is supposed to return the same rows as `SELECT ... WHERE 1 SETTINGS additional_table_filters = {'t': 'p'}`. Such a filter is analyzed against a single table expression rather than a query, so its scope carries `expression_join_tree_node` instead of a join tree. Identifier resolution already handles that scope, but matcher resolution insisted on a query scope with a join tree and threw. Expand the matcher over `expression_join_tree_node` when the scope has one, take the table expression data from the scope itself so an `ALIAS` column still resolves to its expression, and skip the `JOIN USING` retyping step, which has nothing to correct where there is no join. Constant-expression resolution (`QueryAnalyzer::resolveConstantExpression`) also binds a table expression - for a `VALUES` field or an index analysis predicate - but a matcher cannot produce a constant there, so it keeps being rejected: the new scope flag `allow_matcher_from_expression_join_tree_node` is set only on the filter path. This keeps the precise diagnostic pinned by `05051_values_format_names_the_missing_delimiter`. `03571_trying_to_get_name_of_not_a_column_asterisk` pinned the old behaviour indirectly: its fuzzer `DELETE ... WHERE like(indexHint(*, ...), ...)` used to stop at the matcher error, and now reaches the malformed `toNullable(x, y)` call inside the same expression, so `NUMBER_OF_ARGUMENTS_DOESNT_MATCH` is added to that statement's accepted error codes. Closes: https://github.com/ClickHouse/ClickHouse/issues/119157 --- src/Analyzer/Resolve/IdentifierResolveScope.h | 8 ++++ src/Analyzer/Resolve/QueryAnalyzer.cpp | 34 ++++++++++++--- ...g_to_get_name_of_not_a_column_asterisk.sql | 2 +- ...94_matcher_in_additional_filters.reference | 16 ++++++++ .../05194_matcher_in_additional_filters.sql | 41 +++++++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 tests/queries/0_stateless/05194_matcher_in_additional_filters.reference create mode 100644 tests/queries/0_stateless/05194_matcher_in_additional_filters.sql diff --git a/src/Analyzer/Resolve/IdentifierResolveScope.h b/src/Analyzer/Resolve/IdentifierResolveScope.h index 5c1aa4cb21df..fba47738a2e4 100644 --- a/src/Analyzer/Resolve/IdentifierResolveScope.h +++ b/src/Analyzer/Resolve/IdentifierResolveScope.h @@ -235,6 +235,14 @@ struct IdentifierResolveScope */ TableExpressionNodePtr expression_join_tree_node; + /** Whether a matcher may expand the columns of `expression_join_tree_node`. + * Set for an expression that is evaluated per row of that table expression (a row policy, + * `additional_table_filters`, `additional_result_filter`), where a matcher means the same as in + * the equivalent `WHERE`. Not set while resolving a constant expression (a `VALUES` field, an + * index analysis predicate): a matcher cannot produce a constant there and is rejected instead. + */ + bool allow_matcher_from_expression_join_tree_node = false; + /// Node hash to mask id map std::shared_ptr> projection_mask_map; diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index a51f4adf35ae..8e340acf60a1 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -235,6 +235,7 @@ void QueryAnalyzer::resolve(QueryTreeNodePtr & node, const TableExpressionNodePt if (table_expression) { scope.expression_join_tree_node = table_expression; + scope.allow_matcher_from_expression_join_tree_node = true; scope.registered_table_expression_nodes.insert(table_expression); /// Mark table expression as being in resolve process to prevent /// premature access during ALIAS column resolution in @@ -1805,6 +1806,12 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::getMatchedColumnNodesWithN const auto * nearest_query_scope = scope.getNearestQueryScope(); if (nearest_query_scope) table_expression_data = &nearest_query_scope->getTableExpressionDataOrThrow(table_expression_node); + else if (scope.expression_join_tree_node) + { + /// An expression resolved against a single table expression (`additional_table_filters`, a row + /// policy, a column `DEFAULT`) has no query scope, and its table expression data is on this scope. + table_expression_data = &scope.getTableExpressionDataOrThrow(table_expression_node); + } QueryTreeNodes matched_column_nodes; @@ -1870,6 +1877,11 @@ void QueryAnalyzer::updateMatchedColumnsFromJoinUsing( /// If there are no parent query scope or query scope does not have join tree if (!nearest_query_scope_query_node || !nearest_query_scope_query_node->getJoinTreeNode()) { + /// An expression resolved against a single table expression has no join tree, hence no `USING` + /// key whose type a join could have changed. + if (scope.expression_join_tree_node && scope.allow_matcher_from_expression_join_tree_node) + return; + throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "There are no table sources. In scope {}", scope.scope_node->formatASTForErrorMessage()); @@ -2167,8 +2179,18 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( auto * nearest_query_scope = scope.getNearestQueryScope(); auto * nearest_query_scope_query_node = nearest_query_scope ? nearest_query_scope->scope_node->as() : nullptr; - /// If there are no parent query scope or query scope does not have join tree - if (!nearest_query_scope_query_node || !nearest_query_scope_query_node->getJoinTreeNode()) + /** An expression can also be resolved against a single table expression instead of a query: + * `additional_table_filters`, `additional_result_filter`, a row policy or a column `DEFAULT`. + * Such a scope has no query scope and no join tree, and the matcher expands the columns of that + * table expression, so that the filter sees the same columns as the equivalent `WHERE` predicate. + */ + QueryTreeNodePtr matcher_join_tree_node; + if (nearest_query_scope_query_node && nearest_query_scope_query_node->getJoinTreeNode()) + matcher_join_tree_node = nearest_query_scope_query_node->getJoinTreeNode(); + else if (scope.allow_matcher_from_expression_join_tree_node) + matcher_join_tree_node = scope.expression_join_tree_node; + + if (!matcher_join_tree_node) { throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "Unqualified matcher {} cannot be resolved. There are no table sources. In scope {}", @@ -2184,7 +2206,7 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( * expressions that have same names as columns in USING clause must be skipped. */ - auto table_expressions_stack = buildTableExpressionsStack(nearest_query_scope_query_node->getJoinTreeNode()); + auto table_expressions_stack = buildTableExpressionsStack(matcher_join_tree_node); std::vector table_expressions_column_nodes_with_names_stack; std::unordered_set table_expression_column_names_to_skip; @@ -2198,7 +2220,7 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( /// Old-analyzer parity: under two or more JOINs a list-form `COLUMNS` item keeps the written /// identifier. Recorded on a clone, so the shared resolved node keeps its own name. - bool keep_written_names = nearest_query_scope->joins_count >= 2 + bool keep_written_names = nearest_query_scope && nearest_query_scope->joins_count >= 2 && scope.context->getSettingsRef()[Setting::analyzer_compatibility_multiple_joins_qualify_column_names]; for (const auto & identifier : identifiers) @@ -2234,7 +2256,9 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( for (auto & table_expression : table_expressions_stack) { - bool table_expression_in_resolve_process = nearest_query_scope->table_expressions_in_resolve_process.contains(table_expression.get()); + /// Without a query scope the table expressions being resolved are tracked on this scope. + const auto & resolve_process_scope = nearest_query_scope ? *nearest_query_scope : scope; + bool table_expression_in_resolve_process = resolve_process_scope.table_expressions_in_resolve_process.contains(table_expression.get()); if (table_expression->as()) { diff --git a/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql b/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql index 6c33784cc444..fdfa7733f0c3 100644 --- a/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql +++ b/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql @@ -5,5 +5,5 @@ DROP TABLE t0; DROP TABLE IF EXISTS 02577_keepermap_delete_update; CREATE TABLE 02577_keepermap_delete_update (key UInt64, value String, value2 UInt64) PRIMARY KEY(key); -DELETE FROM `02577_keepermap_delete_update` WHERE like(indexHint(*, indexHint(indexHint(toNullable(1.), 0, (20 IS NULL) IS NOT NULL, isNull(indexHint(indexHint(indexHint(indexHint(*), *), isZeroOrNull(materialize(indexHint(*, indexHint(toNullable(toInt128(100) IS NULL, 1.), toLowCardinality(0), isNullable(toNullable(20)) IS NULL, indexHint(isZeroOrNull(15), *, indexHint(*)), *), 100), 15))), indexHint(indexHint(isNullable(materialize(15 IS NOT NULL))), 100, *), 1, indexHint(indexHint(1, indexHint(100, *)), *)), toLowCardinality(15)), indexHint(*)), *)), value, 'Some%string'); -- { serverError UNKNOWN_IDENTIFIER, UNSUPPORTED_METHOD } +DELETE FROM `02577_keepermap_delete_update` WHERE like(indexHint(*, indexHint(indexHint(toNullable(1.), 0, (20 IS NULL) IS NOT NULL, isNull(indexHint(indexHint(indexHint(indexHint(*), *), isZeroOrNull(materialize(indexHint(*, indexHint(toNullable(toInt128(100) IS NULL, 1.), toLowCardinality(0), isNullable(toNullable(20)) IS NULL, indexHint(isZeroOrNull(15), *, indexHint(*)), *), 100), 15))), indexHint(indexHint(isNullable(materialize(15 IS NOT NULL))), 100, *), 1, indexHint(indexHint(1, indexHint(100, *)), *)), toLowCardinality(15)), indexHint(*)), *)), value, 'Some%string'); -- { serverError UNKNOWN_IDENTIFIER, UNSUPPORTED_METHOD, NUMBER_OF_ARGUMENTS_DOESNT_MATCH } DROP TABLE 02577_keepermap_delete_update; diff --git a/tests/queries/0_stateless/05194_matcher_in_additional_filters.reference b/tests/queries/0_stateless/05194_matcher_in_additional_filters.reference new file mode 100644 index 000000000000..0d159396ed42 --- /dev/null +++ b/tests/queries/0_stateless/05194_matcher_in_additional_filters.reference @@ -0,0 +1,16 @@ +WHERE 100 +unqualified 100 +COLUMNS 100 +qualified 100 +transformer 100 +alias column 100 +filtering 94 +filtering by an alias column 94 +result filter +0 +1 +2 +result filter, filtering +2 +row policy 100 +row policy, filtering 94 diff --git a/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql b/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql new file mode 100644 index 000000000000..fc3a8509c0bc --- /dev/null +++ b/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql @@ -0,0 +1,41 @@ +-- A column matcher inside `additional_table_filters`, `additional_result_filter` or a row policy is +-- resolved against the table the filter applies to, just like the same predicate written in `WHERE`: +-- such an expression is analyzed against a single table expression rather than a query, and matcher +-- resolution used to reject that scope with `There are no table sources`. + +DROP TABLE IF EXISTS t_05194; +CREATE TABLE t_05194 (a UInt32, b UInt32, c UInt32 ALIAS a + b) ENGINE = MergeTree ORDER BY a; +INSERT INTO t_05194 SELECT number, number FROM numbers(100); + +SELECT 'WHERE', count() FROM t_05194 WHERE not ignore(*); +SELECT 'unqualified', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(*)'}; +SELECT 'COLUMNS', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(COLUMNS(\'.*\'))'}; +SELECT 'qualified', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(t_05194.*)'}; +SELECT 'transformer', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(* EXCEPT b)'}; +SELECT 'alias column', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(*)'}, asterisk_include_alias_columns = 1; + +-- The filter is applied, not merely accepted. +SELECT 'filtering', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'COLUMNS(\'b\') > 5'}; +SELECT 'filtering by an alias column', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'COLUMNS(\'c\') > 10'}; + +-- `additional_result_filter` is applied on top of the whole plan, so the matcher expands the columns +-- of the query result, and the filter sees the rows the `LIMIT` has already selected. Only a single +-- column is printed: `additional_result_filter` also rearranges the result header, which has nothing +-- to do with the matcher, so the two-column case is checked for resolution only. +SELECT 'result filter'; +SELECT a FROM t_05194 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'not ignore(*)'; +SELECT a, b FROM t_05194 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'not ignore(*)' FORMAT Null; +SELECT 'result filter, filtering'; +SELECT b FROM t_05194 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'COLUMNS(\'b\') > 1'; + +CREATE ROW POLICY OR REPLACE p_05194 ON t_05194 USING not ignore(*) TO ALL; +SELECT 'row policy', count() FROM t_05194; +CREATE ROW POLICY OR REPLACE p_05194 ON t_05194 USING COLUMNS('b') > 5 TO ALL; +SELECT 'row policy, filtering', count() FROM t_05194; +DROP ROW POLICY p_05194 ON t_05194; + +-- A matcher is still rejected where the expression must be a constant: a `VALUES` field is resolved +-- against the target table too, but a matcher cannot produce a constant there. +INSERT INTO t_05194 (a, b) VALUES (*, 1); -- { serverError UNSUPPORTED_METHOD } + +DROP TABLE t_05194; From 4eb8f555e9da026ea31b3826efdd91d6664a4ddf Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 10 Sep 2026 15:56:56 +0000 Subject: [PATCH 067/272] Drop the `VALUES` negative case from 05194 `INSERT INTO t (a, b) VALUES (*, 1)` was accepted, not rejected, under the `Fast test` configuration, so the statement was not testing what it claimed everywhere. The constant-expression rejection is already pinned by `05051_values_format_names_the_missing_delimiter`, which is the test that fixes the error text, so drop the duplicate. https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=119250&sha=d2f6ef11f4365d3b9afb71e9e707ce9f213700c8&name_0=PR&name_1=Fast%20test https://github.com/ClickHouse/ClickHouse/pull/119250 --- .../0_stateless/05194_matcher_in_additional_filters.sql | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql b/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql index fc3a8509c0bc..b2be62855c2f 100644 --- a/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql +++ b/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql @@ -34,8 +34,7 @@ CREATE ROW POLICY OR REPLACE p_05194 ON t_05194 USING COLUMNS('b') > 5 TO ALL; SELECT 'row policy, filtering', count() FROM t_05194; DROP ROW POLICY p_05194 ON t_05194; --- A matcher is still rejected where the expression must be a constant: a `VALUES` field is resolved --- against the target table too, but a matcher cannot produce a constant there. -INSERT INTO t_05194 (a, b) VALUES (*, 1); -- { serverError UNSUPPORTED_METHOD } +-- A matcher is still rejected where the expression must be a constant, which +-- `05051_values_format_names_the_missing_delimiter` pins for a `VALUES` field. DROP TABLE t_05194; From c039f0b9bda4ae22de9c13b7a699e52fef1ba6c9 Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Thu, 10 Sep 2026 16:58:55 +0000 Subject: [PATCH 068/272] Rewrite `extractKeyValuePairs` as a single-pass table-driven scanner The previous implementation was a state machine spread over 12 files that searched for delimiters with the runtime-needle variant of `find_first_symbols`. That variant rebuilds 16 needle registers on every call and runs 16 compares per block regardless of the needle count, and the extractor issued several such calls per pair on haystacks that end within a few bytes. As a side effect the function got 25% faster when a fourth pair delimiter was added, because five needles switch to the precomputed `pcmpestri` path. The new implementation lives in `extractKeyValuePairs.cpp`: - `ByteSet`: a 256-entry membership table plus nibble lookup tables built once per set. The search checks the first 16 bytes one by one (most keys and values are short), then scans 16-byte blocks with two `pshufb` lookups on x86 (`tbl` on AArch64), then finishes the tail. - `Token`: a key or value stays a view into the input until the first decoded escape sequence, then it is accumulated in a scratch buffer. - One structured scanning loop templated on escaping, preserving the exact semantics of the old state machine, including the `INVALID`, `ACCEPT` and `PROMOTE` strategies and best-effort handling of invalid escape sequences. `KeyValuePairExtractor` keeps a small header for `HivePartitioningUtils`, which now receives pairs through a callback instead of catching a dedicated exception type. The old unit tests are ported to a stateless test. Single-threaded throughput, net of string generation: - 7 short pairs per row: 3.4x - log lines with noise: 3.4x - 25 tiny pairs per row: 3.8x - 200-byte values: 2.0x - `extractKeyValuePairsWithEscaping`: 1.4x to 2.0x A differential test over 6.5M random strings dense in delimiters, quotes, backslashes and non-ASCII bytes gives identical output for both functions, all strategies, custom delimiters and the pair limit. Co-Authored-By: Claude Fable 5.1 --- src/Functions/CMakeLists.txt | 4 +- src/Functions/extractKeyValuePairs.cpp | 703 ++++++++++++++--- src/Functions/extractKeyValuePairs.h | 63 ++ .../keyvaluepair/ArgumentExtractor.cpp | 143 ---- .../keyvaluepair/ArgumentExtractor.h | 49 -- src/Functions/keyvaluepair/CMakeLists.txt | 7 - .../impl/CHKeyValuePairExtractor.h | 183 ----- .../keyvaluepair/impl/CMakeLists.txt | 7 - .../keyvaluepair/impl/Configuration.cpp | 106 --- .../keyvaluepair/impl/Configuration.h | 56 -- .../impl/DuplicateKeyFoundException.h | 20 - .../impl/KeyValuePairExtractorBuilder.cpp | 39 - .../impl/KeyValuePairExtractorBuilder.h | 58 -- .../keyvaluepair/impl/NeedleFactory.h | 118 --- .../keyvaluepair/impl/StateHandler.h | 55 -- .../keyvaluepair/impl/StateHandlerImpl.h | 744 ------------------ ...test_escaping_key_value_pair_extractor.cpp | 39 - .../tests/gtest_extractKeyValuePairs.cpp | 173 ---- ...test_inline_escaping_key_state_handler.cpp | 121 --- ...st_inline_escaping_value_state_handler.cpp | 34 - .../gtest_no_escaping_key_state_handler.cpp | 103 --- src/Storages/HivePartitioningUtils.cpp | 22 +- tests/performance/extract_key_value_pairs.xml | 57 ++ ...e_pairs_escaping_and_long_tokens.reference | 126 +++ ...y_value_pairs_escaping_and_long_tokens.sql | 86 ++ 25 files changed, 921 insertions(+), 2195 deletions(-) create mode 100644 src/Functions/extractKeyValuePairs.h delete mode 100644 src/Functions/keyvaluepair/ArgumentExtractor.cpp delete mode 100644 src/Functions/keyvaluepair/ArgumentExtractor.h delete mode 100644 src/Functions/keyvaluepair/CMakeLists.txt delete mode 100644 src/Functions/keyvaluepair/impl/CHKeyValuePairExtractor.h delete mode 100644 src/Functions/keyvaluepair/impl/CMakeLists.txt delete mode 100644 src/Functions/keyvaluepair/impl/Configuration.cpp delete mode 100644 src/Functions/keyvaluepair/impl/Configuration.h delete mode 100644 src/Functions/keyvaluepair/impl/DuplicateKeyFoundException.h delete mode 100644 src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.cpp delete mode 100644 src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.h delete mode 100644 src/Functions/keyvaluepair/impl/NeedleFactory.h delete mode 100644 src/Functions/keyvaluepair/impl/StateHandler.h delete mode 100644 src/Functions/keyvaluepair/impl/StateHandlerImpl.h delete mode 100644 src/Functions/keyvaluepair/tests/gtest_escaping_key_value_pair_extractor.cpp delete mode 100644 src/Functions/keyvaluepair/tests/gtest_extractKeyValuePairs.cpp delete mode 100644 src/Functions/keyvaluepair/tests/gtest_inline_escaping_key_state_handler.cpp delete mode 100644 src/Functions/keyvaluepair/tests/gtest_inline_escaping_value_state_handler.cpp delete mode 100644 src/Functions/keyvaluepair/tests/gtest_no_escaping_key_state_handler.cpp create mode 100644 tests/performance/extract_key_value_pairs.xml create mode 100644 tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.reference create mode 100644 tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.sql diff --git a/src/Functions/CMakeLists.txt b/src/Functions/CMakeLists.txt index dfd6059e4662..2313961e4129 100644 --- a/src/Functions/CMakeLists.txt +++ b/src/Functions/CMakeLists.txt @@ -59,6 +59,7 @@ set(DBMS_FUNCTIONS extractTimeZoneFromFunctionArguments.cpp # extractTimeZoneFromFunctionArguments (DateTimeTransforms.h, FunctionsConversion.cpp) generateSnowflakeID.cpp FunctionGenerateRandomStructure.cpp # FunctionGenerateRandomStructure::generateRandomDataType (Used by StorageGenerateRandom.cpp) + extractKeyValuePairs.cpp # KeyValuePairExtractor (Used by HivePartitioningUtils.cpp) ) extract_into_parent_list(clickhouse_functions_sources dbms_sources ${DBMS_FUNCTIONS}) extract_into_parent_list(clickhouse_functions_headers dbms_headers @@ -69,6 +70,7 @@ extract_into_parent_list(clickhouse_functions_headers dbms_headers extractTimeZoneFromFunctionArguments.h FunctionsLogical.h CastOverloadResolver.h + extractKeyValuePairs.h ) add_library(clickhouse_functions_obj OBJECT ${clickhouse_functions_headers} ${clickhouse_functions_sources}) @@ -185,8 +187,6 @@ endif() add_subdirectory(JSONPath) list (APPEND PRIVATE_LIBS clickhouse_functions_jsonpath) -add_subdirectory(keyvaluepair) - # Signed integer overflow on user-provided data inside boost::geometry - ignore. set_source_files_properties("pointInPolygon.cpp" PROPERTIES COMPILE_FLAGS -fno-sanitize=signed-integer-overflow) diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index 24af8262784f..4064663b2188 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -1,17 +1,29 @@ -#include +#include + #include +#include +#include #include - -#include -#include - #include #include - +#include +#include +#include +#include +#include #include +#include +#include + +#include + +#if defined(__SSSE3__) +# include +#endif +#if defined(__aarch64__) +# include +#endif -#include -#include namespace DB { @@ -23,147 +35,588 @@ namespace Setting namespace ErrorCodes { -extern const int BAD_ARGUMENTS; + extern const int BAD_ARGUMENTS; + extern const int ILLEGAL_TYPE_OF_ARGUMENT; + extern const int LIMIT_EXCEEDED; + extern const int NUMBER_OF_ARGUMENTS_DOESNT_MATCH; } +namespace +{ + +constexpr char ESCAPE_CHARACTER = '\\'; + +/// A set of bytes with a vectorized search for the first byte that is (or is not) in the set. +/// The lookup tables are built once per set, so a search has no setup cost. +class ByteSet +{ +public: + void add(char c) + { + auto byte = static_cast(c); + if (table[byte]) + return; + + table[byte] = true; + + /// The vectorized search classifies a byte by two nibble lookups: it is in the set iff + /// `low_nibble_table[low] & high_nibble_table[high]` is non-zero, where each distinct high + /// nibble present in the set owns one bit. That supports at most 8 distinct high nibbles, + /// which covers any set of ASCII punctuation; other sets use the scalar search only. + UInt8 high = byte >> 4; + UInt8 low = byte & 0x0F; + if (!high_nibble_bit[high]) + { + if (num_high_nibbles == 8) + { + vectorized = false; + return; + } + + high_nibble_bit[high] = static_cast(1u << num_high_nibbles); + ++num_high_nibbles; + } + + low_nibble_table[low] |= high_nibble_bit[high]; + high_nibble_table[high] = high_nibble_bit[high]; + } + + bool contains(char c) const + { + return table[static_cast(c)]; + } + + /// Returns the first byte in [begin, end) whose membership in the set equals `positive`, or `end`. + template + const char * find(const char * begin, const char * end) const + { + const char * pos = begin; + + /// Most keys and values are short, so the first bytes are checked one by one + /// before paying for a vector iteration that would mostly look past the token. + const char * scalar_end = end - pos > SCALAR_PREFIX ? pos + SCALAR_PREFIX : end; + for (; pos < scalar_end; ++pos) + { + if (contains(*pos) == positive) + return pos; + } + + if (vectorized) + pos = findVectorized(pos, end); + + for (; pos < end; ++pos) + { + if (contains(*pos) == positive) + return pos; + } + + return end; + } + +private: + static constexpr ptrdiff_t SCALAR_PREFIX = 16; + static constexpr ptrdiff_t VECTOR_SIZE = 16; + + /// Scans whole 16-byte blocks. Returns the position of the first byte matching the search, + /// or the position from which fewer than 16 bytes remain. + template + const char * findVectorized(const char * pos, const char * end) const + { +#if defined(__SSSE3__) + const __m128i low_table = _mm_loadu_si128(reinterpret_cast(low_nibble_table)); + const __m128i high_table = _mm_loadu_si128(reinterpret_cast(high_nibble_table)); + const __m128i low_mask = _mm_set1_epi8(0x0F); + const __m128i zero = _mm_setzero_si128(); + + for (; end - pos >= VECTOR_SIZE; pos += VECTOR_SIZE) + { + __m128i bytes = _mm_loadu_si128(reinterpret_cast(pos)); + __m128i low = _mm_shuffle_epi8(low_table, _mm_and_si128(bytes, low_mask)); + __m128i high = _mm_shuffle_epi8(high_table, _mm_and_si128(_mm_srli_epi16(bytes, 4), low_mask)); + __m128i not_in_set = _mm_cmpeq_epi8(_mm_and_si128(low, high), zero); + auto mask = static_cast(_mm_movemask_epi8(not_in_set)); + if constexpr (positive) + mask = ~mask & 0xFFFFu; + if (mask) + return pos + std::countr_zero(mask); + } +#elif defined(__aarch64__) + const uint8x16_t low_table = vld1q_u8(low_nibble_table); + const uint8x16_t high_table = vld1q_u8(high_nibble_table); + const uint8x16_t low_mask = vdupq_n_u8(0x0F); + + for (; end - pos >= VECTOR_SIZE; pos += VECTOR_SIZE) + { + uint8x16_t bytes = vld1q_u8(reinterpret_cast(pos)); + uint8x16_t low = vqtbl1q_u8(low_table, vandq_u8(bytes, low_mask)); + uint8x16_t high = vqtbl1q_u8(high_table, vshrq_n_u8(bytes, 4)); + uint8x16_t not_in_set = vceqzq_u8(vandq_u8(low, high)); + /// Narrow each 0x00/0xFF byte of the comparison result to a nibble of a 64-bit mask. + UInt64 mask = vget_lane_u64(vreinterpret_u64_u8(vshrn_n_u16(vreinterpretq_u16_u8(not_in_set), 4)), 0); + if constexpr (positive) + mask = ~mask; + if (mask) + return pos + (std::countr_zero(mask) >> 2); + } +#endif + return pos; + } + + bool table[256]{}; + alignas(16) UInt8 low_nibble_table[16]{}; + alignas(16) UInt8 high_nibble_table[16]{}; + UInt8 high_nibble_bit[16]{}; + size_t num_high_nibbles = 0; + bool vectorized = true; +}; + +/// A key or a value being read. Without escape sequences it is a view into the input; +/// after the first decoded escape sequence it is accumulated in `buffer`. +class Token +{ +public: + explicit Token(std::string & buffer_) : buffer(buffer_) {} + + void start(const char * pos) + { + chunk_begin = pos; + in_buffer = false; + } + + /// Decodes the escape sequence at `pos` (the backslash) and moves `pos` past it. + /// Returns false if the sequence is invalid; `pos` is still moved past the consumed bytes. + bool consumeEscapeSequence(const char *& pos, const char * end) + { + if (!in_buffer) + { + buffer.clear(); + in_buffer = true; + } + buffer.append(chunk_begin, pos); + + ReadBufferFromMemory in(pos, end - pos); + bool ok = parseComplexEscapeSequence(buffer, in); + pos = in.position(); + chunk_begin = pos; + return ok; + } + + /// Returns the token read so far, ending at `pos`. + std::string_view finish(const char * pos) + { + if (!in_buffer) + return {chunk_begin, pos}; + + buffer.append(chunk_begin, pos); + chunk_begin = pos; + return buffer; + } + +private: + std::string & buffer; + const char * chunk_begin = nullptr; + bool in_buffer = false; +}; + +/// What ended the reading of a token. +enum class Stop +{ + KeyValueDelimiter, + PairDelimiter, + QuotingCharacter, + End, + InvalidEscapeSequence, +}; + +} + +struct KeyValuePairExtractor::Impl +{ + Configuration configuration; + + /// Bytes skipped while waiting for a key: the delimiters, and the escape character with escaping. + ByteSet waiting_key_skip; + /// Bytes that end an unquoted key: both delimiters, the quoting character unless the strategy is + /// `ACCEPT`, and the escape character with escaping. + ByteSet key_stop; + /// Bytes that end an unquoted value: same as for a key, except the key-value delimiter is a regular byte. + ByteSet value_stop; + /// Bytes that end a quoted key or value: the quoting character, and the escape character with escaping. + ByteSet quoted_stop; + ByteSet pair_delimiters; + + explicit Impl(const Configuration & configuration_) + : configuration(configuration_) + { + validate(); + + waiting_key_skip.add(configuration.key_value_delimiter); + key_stop.add(configuration.key_value_delimiter); + + for (char c : configuration.pair_delimiters) + { + waiting_key_skip.add(c); + key_stop.add(c); + value_stop.add(c); + pair_delimiters.add(c); + } + + if (configuration.unexpected_quoting_character_strategy != UnexpectedQuotingCharacterStrategy::ACCEPT) + { + key_stop.add(configuration.quoting_character); + value_stop.add(configuration.quoting_character); + } + + quoted_stop.add(configuration.quoting_character); + + if (configuration.with_escaping) + { + waiting_key_skip.add(ESCAPE_CHARACTER); + key_stop.add(ESCAPE_CHARACTER); + value_stop.add(ESCAPE_CHARACTER); + quoted_stop.add(ESCAPE_CHARACTER); + } + } + + void validate() const + { + const auto & pair_delimiters_str = configuration.pair_delimiters; + + if (configuration.key_value_delimiter == configuration.quoting_character) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, key_value_delimiter and quoting_character can not be the same"); + + if (pair_delimiters_str.size() > MAX_NUMBER_OF_PAIR_DELIMITERS) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, pair delimiters can contain at most {} characters", MAX_NUMBER_OF_PAIR_DELIMITERS); + + if (pair_delimiters_str.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, pair delimiters list is empty"); + + if (pair_delimiters_str.contains(configuration.key_value_delimiter)) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, key_value_delimiter conflicts with pair delimiters"); + + if (pair_delimiters_str.contains(configuration.quoting_character)) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, quoting_character conflicts with pair delimiters"); + + if (configuration.with_escaping + && (configuration.key_value_delimiter == ESCAPE_CHARACTER + || configuration.quoting_character == ESCAPE_CHARACTER + || pair_delimiters_str.contains(ESCAPE_CHARACTER))) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, {} is reserved for the escaping character", ESCAPE_CHARACTER); + } + + /// Reads a token starting at `pos` until a byte from `stop_set` and moves `pos` past that byte + /// (or to `end`). With escaping, escape sequences are decoded into the token. + template + Stop readToken(const ByteSet & stop_set, Token & token, std::string_view & result, const char *& pos, const char * end) const + { + token.start(pos); + + while (true) + { + pos = stop_set.find(pos, end); + if (pos == end) + { + result = token.finish(pos); + return Stop::End; + } + + char c = *pos; + if constexpr (with_escaping) + { + if (c == ESCAPE_CHARACTER) + { + if (token.consumeEscapeSequence(pos, end)) + continue; + result = token.finish(pos); + return Stop::InvalidEscapeSequence; + } + } + + result = token.finish(pos); + ++pos; + + if (c == configuration.key_value_delimiter) + return Stop::KeyValueDelimiter; + if (c == configuration.quoting_character) + return Stop::QuotingCharacter; + return Stop::PairDelimiter; + } + } + + template + size_t extractImpl(std::string_view data, OnPair && on_pair) const + { + const char * pos = data.data(); + const char * const end = pos + data.size(); + size_t num_pairs = 0; + + /// Used only when escape sequences are decoded, otherwise keys and values are views into `data`. + std::string key_buffer; + std::string value_buffer; + Token key(key_buffer); + Token value(value_buffer); + std::string_view key_view; + std::string_view value_view; + + auto commit = [&] + { + ++num_pairs; + if (configuration.max_number_of_pairs && num_pairs > configuration.max_number_of_pairs) + throw Exception(ErrorCodes::LIMIT_EXCEEDED, "Number of pairs produced exceeded the limit of {}", configuration.max_number_of_pairs); + on_pair(key_view, value_view); + }; + + const auto strategy = configuration.unexpected_quoting_character_strategy; + + while (true) + { + /// Waiting for a key. + pos = waiting_key_skip.find(pos, end); + if (pos == end) + return num_pairs; + + bool quoted_key = *pos == configuration.quoting_character; + if (quoted_key) + ++pos; + else + { + /// Reading an unquoted key. + Stop stop = readToken(key_stop, key, key_view, pos, end); + if (stop == Stop::End) + return num_pairs; + if (stop == Stop::PairDelimiter || stop == Stop::InvalidEscapeSequence) + continue; + if (stop == Stop::QuotingCharacter) + { + if (strategy == UnexpectedQuotingCharacterStrategy::INVALID) + continue; + /// PROMOTE: discard what was read and read a quoted key instead. + quoted_key = true; + } + } + + if (quoted_key) + { + /// Reading a quoted key. + Stop stop = readToken(quoted_stop, key, key_view, pos, end); + if (stop == Stop::End) + return num_pairs; + if (stop == Stop::InvalidEscapeSequence || key_view.empty()) + continue; + /// The closing quote must be followed by the key-value delimiter. + if (pos == end || *pos != configuration.key_value_delimiter) + continue; + ++pos; + } + + /// Waiting for a value. + bool quoted_value = false; + if (pos != end) + { + if (*pos == configuration.quoting_character) + { + quoted_value = true; + ++pos; + } + else if (with_escaping && *pos == ESCAPE_CHARACTER) + { + /// A value cannot start with an escape sequence, the key is discarded. + continue; + } + } + + if (!quoted_value) + { + /// Reading an unquoted value. It ends at a pair delimiter or at the end of the input. + /// An invalid escape sequence ends it as well and what was read so far is kept. + Stop stop = readToken(value_stop, value, value_view, pos, end); + if (stop != Stop::QuotingCharacter) + { + commit(); + if (pos == end) + return num_pairs; + continue; + } + if (strategy == UnexpectedQuotingCharacterStrategy::INVALID) + continue; + /// PROMOTE: discard what was read and read a quoted value instead. + } + + /// Reading a quoted value. + Stop stop = readToken(quoted_stop, value, value_view, pos, end); + if (stop == Stop::End) + return num_pairs; + if (stop == Stop::InvalidEscapeSequence) + continue; + commit(); + + /// After a quoted value only a pair delimiter may follow, everything up to it is skipped. + pos = pair_delimiters.find(pos, end); + if (pos == end) + return num_pairs; + ++pos; + } + } + + template + size_t extract(std::string_view data, OnPair && on_pair) const + { + return configuration.with_escaping ? extractImpl(data, on_pair) : extractImpl(data, on_pair); + } + + static constexpr size_t MAX_NUMBER_OF_PAIR_DELIMITERS = 8; +}; + +KeyValuePairExtractor::KeyValuePairExtractor(const Configuration & configuration_) + : impl(std::make_unique(configuration_)) +{ +} + +KeyValuePairExtractor::~KeyValuePairExtractor() = default; + +size_t KeyValuePairExtractor::extract(std::string_view data, ColumnString & keys, ColumnString & values) const +{ + return impl->extract(data, [&](std::string_view key, std::string_view value) + { + keys.insertData(key.data(), key.size()); + values.insertData(value.data(), value.size()); + }); +} + +size_t KeyValuePairExtractor::forEachPair(std::string_view data, const PairCallback & on_pair) const +{ + return impl->extract(data, on_pair); +} + +namespace +{ + class ExtractKeyValuePairs final : public IFunction { - KeyValuePairExtractorBuilder getBuilder(const ArgumentExtractor::ParsedArguments & parsed_arguments) const - { - auto builder = KeyValuePairExtractorBuilder(); - - if (parsed_arguments.key_value_delimiter) - { - builder.withKeyValueDelimiter(parsed_arguments.key_value_delimiter.value()); - } - - if (!parsed_arguments.pair_delimiters.empty()) - { - builder.withItemDelimiters(parsed_arguments.pair_delimiters); - } - - if (parsed_arguments.quoting_character) - { - builder.withQuotingCharacter(parsed_arguments.quoting_character.value()); - } - - bool is_number_of_pairs_unlimited = extract_key_value_pairs_max_pairs_per_row == 0; - if (!is_number_of_pairs_unlimited) - { - builder.withMaxNumberOfPairs(extract_key_value_pairs_max_pairs_per_row); - } - - if (parsed_arguments.unexpected_quoting_character_strategy) - { - const std::string unexpected_quoting_character_strategy_string{parsed_arguments.unexpected_quoting_character_strategy->getDataAt(0)}; - const auto unexpected_quoting_character_strategy = magic_enum::enum_cast( - unexpected_quoting_character_strategy_string, magic_enum::case_insensitive); - - if (!unexpected_quoting_character_strategy) - { - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid unexpected_quoting_character_strategy argument: {}", unexpected_quoting_character_strategy_string); - } - - builder.withUnexpectedQuotingCharacterStrategy(unexpected_quoting_character_strategy.value()); - } - - return builder; - } - - ColumnPtr extract(ColumnPtr data_column, auto & extractor, size_t input_rows_count) const - { - auto offsets = ColumnUInt64::create(); - - auto keys = ColumnString::create(); - auto values = ColumnString::create(); - - uint64_t offset = 0u; - - for (auto i = 0u; i < input_rows_count; i++) - { - auto row = data_column->getDataAt(i); - - auto pairs_count = extractor.extract(row, keys, values); - - offset += pairs_count; - - offsets->insert(offset); - } - - keys->validate(); - values->validate(); - - ColumnPtr keys_ptr = std::move(keys); - - return ColumnMap::create(keys_ptr, std::move(values), std::move(offsets)); - } - public: - ExtractKeyValuePairs(ContextPtr context, const char * name_, bool with_escaping_) - : extract_key_value_pairs_max_pairs_per_row(context->getSettingsRef()[Setting::extract_key_value_pairs_max_pairs_per_row]) - , function_name(name_) + ExtractKeyValuePairs(ContextPtr context, String name_, bool with_escaping_) + : max_number_of_pairs(context->getSettingsRef()[Setting::extract_key_value_pairs_max_pairs_per_row]) + , name(std::move(name_)) , with_escaping(with_escaping_) - {} - - String getName() const override { - return function_name; } - static FunctionPtr create(ContextPtr context, const char * name, bool with_escaping) + static FunctionPtr create(ContextPtr context, String name, bool with_escaping) { - return std::make_shared(context, name, with_escaping); + return std::make_shared(context, std::move(name), with_escaping); + } + + String getName() const override { return name; } + bool isVariadic() const override { return true; } + size_t getNumberOfArguments() const override { return 0; } + bool isSuitableForShortCircuitArgumentsExecution(const DataTypesWithConstInfo &) const override { return false; } + ColumnNumbers getArgumentsThatAreAlwaysConstant() const override { return {1, 2, 3, 4}; } + + DataTypePtr getReturnTypeImpl(const ColumnsWithTypeAndName & arguments) const override + { + if (arguments.empty() || arguments.size() > MAX_NUMBER_OF_ARGUMENTS) + throw Exception(ErrorCodes::NUMBER_OF_ARGUMENTS_DOESNT_MATCH, + "Function {} requires at least 1 argument and at most {}. {} was provided", + getName(), MAX_NUMBER_OF_ARGUMENTS, arguments.size()); + + for (size_t i = 0; i < arguments.size(); ++i) + if (!isStringOrFixedString(arguments[i].type)) + throw Exception(ErrorCodes::ILLEGAL_TYPE_OF_ARGUMENT, + "Illegal type {} of argument {}. Must be String.", arguments[i].type, ARGUMENT_NAMES[i]); + + return std::make_shared(std::make_shared(), std::make_shared()); } ColumnPtr executeImpl(const ColumnsWithTypeAndName & arguments, const DataTypePtr &, size_t input_rows_count) const override { - auto parsed_arguments = ArgumentExtractor::extract(arguments); + KeyValuePairExtractor extractor(getConfiguration(arguments)); + const auto & data_column = *arguments[0].column; - auto builder = getBuilder(parsed_arguments); + auto keys = ColumnString::create(); + auto values = ColumnString::create(); + auto offsets = ColumnUInt64::create(); + auto & offsets_data = offsets->getData(); + offsets_data.reserve(input_rows_count); - if (with_escaping) + UInt64 offset = 0; + for (size_t i = 0; i < input_rows_count; ++i) { - auto extractor = builder.buildWithEscaping(); - return extract(parsed_arguments.data_column, extractor, input_rows_count); + offset += extractor.extract(data_column.getDataAt(i), *keys, *values); + offsets_data.push_back(offset); } - else - { - auto extractor = builder.buildWithoutEscaping(); - return extract(parsed_arguments.data_column, extractor, input_rows_count); - } - } - DataTypePtr getReturnTypeImpl(const DataTypes &) const override - { - return std::make_shared(std::make_shared(), std::make_shared()); - } - - bool isVariadic() const override - { - return true; - } - - bool isSuitableForShortCircuitArgumentsExecution(const DataTypesWithConstInfo &) const override - { - return false; - } - - std::size_t getNumberOfArguments() const override - { - return 0u; - } - - ColumnNumbers getArgumentsThatAreAlwaysConstant() const override - { - return {1, 2, 3, 4, 5}; + return ColumnMap::create(ColumnPtr(std::move(keys)), ColumnPtr(std::move(values)), ColumnPtr(std::move(offsets))); } private: - const UInt64 extract_key_value_pairs_max_pairs_per_row; - const char * function_name; - bool with_escaping; + static constexpr size_t MAX_NUMBER_OF_ARGUMENTS = 5; + static constexpr std::array ARGUMENT_NAMES = + { + "data_column", + "key_value_delimiter", + "pair_delimiters", + "quoting_character", + "unexpected_quoting_character_strategy" + }; + + KeyValuePairExtractor::Configuration getConfiguration(const ColumnsWithTypeAndName & arguments) const + { + KeyValuePairExtractor::Configuration configuration; + configuration.with_escaping = with_escaping; + configuration.max_number_of_pairs = max_number_of_pairs; + + /// An empty argument keeps the default. + if (arguments.size() > 1) + { + if (auto c = getCharacterArgument(arguments[1])) + configuration.key_value_delimiter = *c; + } + + if (arguments.size() > 2) + { + if (auto pair_delimiters = arguments[2].column->getDataAt(0); !pair_delimiters.empty()) + configuration.pair_delimiters = pair_delimiters; + } + + if (arguments.size() > 3) + { + if (auto c = getCharacterArgument(arguments[3])) + configuration.quoting_character = *c; + } + + if (arguments.size() > 4) + { + auto strategy_name = arguments[4].column->getDataAt(0); + auto strategy = magic_enum::enum_cast(strategy_name, magic_enum::case_insensitive); + + if (!strategy) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid unexpected_quoting_character_strategy argument: {}", strategy_name); + + configuration.unexpected_quoting_character_strategy = *strategy; + } + + return configuration; + } + + static std::optional getCharacterArgument(const ColumnWithTypeAndName & argument) + { + auto value = argument.column->getDataAt(0); + if (value.empty()) + return {}; + + if (value.size() == 1) + return value.front(); + + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Control character argument must either be empty or contain exactly 1 character"); + } + + const UInt64 max_number_of_pairs; + const String name; + const bool with_escaping; }; +} + REGISTER_FUNCTION(ExtractKeyValuePairs) { factory.registerFunction("extractKeyValuePairs", [](ContextPtr ctx){ return ExtractKeyValuePairs::create(ctx, "extractKeyValuePairs", false); }, diff --git a/src/Functions/extractKeyValuePairs.h b/src/Functions/extractKeyValuePairs.h new file mode 100644 index 000000000000..7cc7a2bea385 --- /dev/null +++ b/src/Functions/extractKeyValuePairs.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace DB +{ + +class ColumnString; + +/// Extracts `keyvalue` pairs separated by pair delimiters from a string that may +/// contain arbitrary noise between the pairs. Keys and values may be quoted. This is the +/// implementation of the `extractKeyValuePairs` function; the grammar is described in its +/// documentation. It is also used to parse Hive-style partitioning paths. +class KeyValuePairExtractor +{ +public: + /// What to do with a quoting character in the middle of an unquoted key or value. + enum class UnexpectedQuotingCharacterStrategy + { + /// Discard the key or value being read. + INVALID, + /// Treat it as a regular character. + ACCEPT, + /// Discard what was read so far and continue as a quoted key or value. + PROMOTE, + }; + + struct Configuration + { + char key_value_delimiter = ':'; + std::string pair_delimiters = " ,;"; + char quoting_character = '"'; + UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy = UnexpectedQuotingCharacterStrategy::PROMOTE; + /// Decode escape sequences; the backslash becomes a reserved character. + bool with_escaping = false; + /// 0 means unlimited. + UInt64 max_number_of_pairs = 0; + }; + + /// Throws `BAD_ARGUMENTS` if the configuration is inconsistent. + explicit KeyValuePairExtractor(const Configuration & configuration_); + ~KeyValuePairExtractor(); + + /// Appends the extracted keys and values to the columns. Returns the number of pairs. + size_t extract(std::string_view data, ColumnString & keys, ColumnString & values) const; + + using PairCallback = std::function; + + /// Calls `on_pair` for every extracted pair. Without escaping the views point into `data`; + /// with escaping they may point into a scratch buffer and are valid only during the call. + size_t forEachPair(std::string_view data, const PairCallback & on_pair) const; + +private: + struct Impl; + std::unique_ptr impl; +}; + +} diff --git a/src/Functions/keyvaluepair/ArgumentExtractor.cpp b/src/Functions/keyvaluepair/ArgumentExtractor.cpp deleted file mode 100644 index 5b63d3a8b610..000000000000 --- a/src/Functions/keyvaluepair/ArgumentExtractor.cpp +++ /dev/null @@ -1,143 +0,0 @@ -#include - -#include - -#include - -namespace DB -{ - -namespace ErrorCodes -{ - extern const int NUMBER_OF_ARGUMENTS_DOESNT_MATCH; - extern const int BAD_ARGUMENTS; - extern const int ILLEGAL_TYPE_OF_ARGUMENT; -} - -namespace -{ - auto popFrontAndGet(auto & container) - { - auto element = container.front(); - container.pop_front(); - return element; - } -} - -ArgumentExtractor::ParsedArguments ArgumentExtractor::extract(const ColumnsWithTypeAndName & arguments) -{ - return extract(ColumnsWithTypeAndNameList{arguments.begin(), arguments.end()}); -} - -ArgumentExtractor::ParsedArguments ArgumentExtractor::extract(ColumnsWithTypeAndNameList arguments) -{ - static constexpr auto MAX_NUMBER_OF_ARGUMENTS = 5u; - - if (arguments.empty() || arguments.size() > MAX_NUMBER_OF_ARGUMENTS) - { - throw Exception(ErrorCodes::NUMBER_OF_ARGUMENTS_DOESNT_MATCH, - "Function extractKeyValuePairs requires at least 1 argument and at most {}. {} was provided", - MAX_NUMBER_OF_ARGUMENTS, arguments.size()); - } - - auto data_column = extractStringColumn(popFrontAndGet(arguments), "data_column"); - - if (arguments.empty()) - { - return ParsedArguments{data_column}; - } - - auto key_value_delimiter = extractSingleCharacter(popFrontAndGet(arguments), "key_value_delimiter"); - - if (arguments.empty()) - { - return ParsedArguments {data_column, key_value_delimiter}; - } - - auto pair_delimiters = extractVector(popFrontAndGet(arguments), "pair_delimiters"); - - if (arguments.empty()) - { - return ParsedArguments { - data_column, key_value_delimiter, pair_delimiters - }; - } - - auto quoting_character = extractSingleCharacter(popFrontAndGet(arguments), "quoting_character"); - - if (arguments.empty()) - { - return ParsedArguments { - data_column, - key_value_delimiter, - pair_delimiters, - quoting_character - }; - } - - auto unexpected_quoting_character_strategy = extractStringColumn(popFrontAndGet(arguments), "unexpected_quoting_character_strategy"); - - return ParsedArguments { - data_column, - key_value_delimiter, - pair_delimiters, - quoting_character, - unexpected_quoting_character_strategy - }; -} - -ArgumentExtractor::CharArgument ArgumentExtractor::extractSingleCharacter(const ColumnWithTypeAndName & argument, const std::string & parameter_name) -{ - const auto type = argument.type; - const auto column = argument.column; - - validateColumnType(type, parameter_name); - - auto view = column->getDataAt(0); - - if (view.empty()) - { - return {}; - } - if (view.size() == 1u) - { - return view.front(); - } - - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Control character argument must either be empty or contain exactly 1 character"); -} - -ColumnPtr ArgumentExtractor::extractStringColumn(const ColumnWithTypeAndName & argument, const std::string & parameter_name) -{ - auto type = argument.type; - auto column = argument.column; - - validateColumnType(type, parameter_name); - - return column; -} - -ArgumentExtractor::VectorArgument ArgumentExtractor::extractVector(const ColumnWithTypeAndName & argument, const std::string & parameter_name) -{ - const auto type = argument.type; - const auto column = argument.column; - - validateColumnType(type, parameter_name); - - auto view = column->getDataAt(0); - - return {view.begin(), view.end()}; -} - -void ArgumentExtractor::validateColumnType(DataTypePtr type, const std::string & parameter_name) -{ - if (!isStringOrFixedString(type)) - { - throw Exception( - ErrorCodes::ILLEGAL_TYPE_OF_ARGUMENT, - "Illegal type {} of argument {}. Must be String.", - type, parameter_name); - } -} - -} diff --git a/src/Functions/keyvaluepair/ArgumentExtractor.h b/src/Functions/keyvaluepair/ArgumentExtractor.h deleted file mode 100644 index 5cbd8381a381..000000000000 --- a/src/Functions/keyvaluepair/ArgumentExtractor.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include - -namespace DB -{ - -class IDataType; -using DataTypePtr = std::shared_ptr; - -/* - * Validate (API level validation, no business logic validation) and extracts input arguments from - * `ColumnsWithTypeAndName` into ArgumentExtractor::ParsedArguments. - * */ -class ArgumentExtractor -{ -public: - using CharArgument = std::optional; - using VectorArgument = VectorWithMemoryTracking; - using ColumnsWithTypeAndNameList = ListWithMemoryTracking; - - struct ParsedArguments - { - ColumnPtr data_column; - - CharArgument key_value_delimiter = {}; - VectorArgument pair_delimiters = {}; - CharArgument quoting_character = {}; - ColumnPtr unexpected_quoting_character_strategy = nullptr; - }; - - - static ParsedArguments extract(const ColumnsWithTypeAndName & arguments); - static ParsedArguments extract(ColumnsWithTypeAndNameList arguments); - -private: - static CharArgument extractSingleCharacter(const ColumnWithTypeAndName & arguments, const std::string & parameter_name); - static ColumnPtr extractStringColumn(const ColumnWithTypeAndName & arguments, const std::string & parameter_name); - static VectorArgument extractVector(const ColumnWithTypeAndName & arguments, const std::string & parameter_name); - - static void validateColumnType(DataTypePtr type, const std::string & parameter_name); -}; - -} diff --git a/src/Functions/keyvaluepair/CMakeLists.txt b/src/Functions/keyvaluepair/CMakeLists.txt deleted file mode 100644 index f5997fe82ad2..000000000000 --- a/src/Functions/keyvaluepair/CMakeLists.txt +++ /dev/null @@ -1,7 +0,0 @@ -include("${ClickHouse_SOURCE_DIR}/cmake/dbms_glob_sources.cmake") -add_headers_and_sources(clickhouse_functions_extractkeyvaluepairs .) -add_headers_and_sources(clickhouse_functions_extractkeyvaluepairs impl) - -# Used in VirtualColumnsUtils (makeExtractor) -extract_into_parent_list(clickhouse_functions_extractkeyvaluepairs_sources dbms_sources ${clickhouse_functions_extractkeyvaluepairs_sources}) -extract_into_parent_list(clickhouse_functions_extractkeyvaluepairs_headers dbms_headers ${clickhouse_functions_extractkeyvaluepairs_headers}) \ No newline at end of file diff --git a/src/Functions/keyvaluepair/impl/CHKeyValuePairExtractor.h b/src/Functions/keyvaluepair/impl/CHKeyValuePairExtractor.h deleted file mode 100644 index 056ad6327144..000000000000 --- a/src/Functions/keyvaluepair/impl/CHKeyValuePairExtractor.h +++ /dev/null @@ -1,183 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include - -namespace DB -{ - -namespace ErrorCodes -{ - extern const int LOGICAL_ERROR; - extern const int LIMIT_EXCEEDED; -} - -namespace extractKV -{ -/* - * Thread-safe key value pair extractor. This class in particular handles state transitions. - * */ -template -class KeyValuePairExtractor -{ - using State = typename DB::extractKV::StateHandler::State; - using NextState = DB::extractKV::StateHandler::NextState; - -public: - using PairWriter = typename StateHandler::PairWriter; - - KeyValuePairExtractor(const Configuration & configuration_, uint64_t max_number_of_pairs_) - : state_handler(StateHandler(configuration_)) - , max_number_of_pairs(max_number_of_pairs_) - { - } - -protected: - uint64_t extractImpl(std::string_view data, typename StateHandler::PairWriter & pair_writer) const - { - auto state = State::WAITING_KEY; - - uint64_t row_offset = 0; - - while (state != State::END) - { - auto next_state = processState(data, state, pair_writer, row_offset); - - if (next_state.position_in_string > data.size() && next_state.state != State::END) - { - throw Exception(ErrorCodes::LOGICAL_ERROR, - "Attempt to move read pointer past end of available data, from state {} to new state: {}, new position: {}, available data: {}", - magic_enum::enum_name(state), magic_enum::enum_name(next_state.state), - next_state.position_in_string, data.size()); - } - - data.remove_prefix(next_state.position_in_string); - state = next_state.state; - } - - // below reset discards invalid keys and values - reset(pair_writer); - - return row_offset; - } - -private: - void incrementRowOffset(uint64_t & row_offset) const - { - row_offset++; - - if (row_offset > max_number_of_pairs) - { - throw Exception(ErrorCodes::LIMIT_EXCEEDED, "Number of pairs produced exceeded the limit of {}", max_number_of_pairs); - } - } - - NextState processState(std::string_view file, State state, auto & pair_writer, uint64_t & row_offset) const - { - switch (state) - { - case State::WAITING_KEY: - { - return state_handler.waitKey(file); - } - case State::READING_KEY: - { - return state_handler.readKey(file, pair_writer); - } - case State::READING_QUOTED_KEY: - { - return state_handler.readQuotedKey(file, pair_writer); - } - case State::READING_KV_DELIMITER: - { - return state_handler.readKeyValueDelimiter(file); - } - case State::WAITING_VALUE: - { - return state_handler.waitValue(file); - } - case State::READING_VALUE: - { - return state_handler.readValue(file, pair_writer); - } - case State::READING_QUOTED_VALUE: - { - return state_handler.readQuotedValue(file, pair_writer); - } - case State::FLUSH_PAIR: - { - incrementRowOffset(row_offset); - return state_handler.flushPair(file, pair_writer); - } - case State::FLUSH_PAIR_AFTER_QUOTED_VALUE: - { - incrementRowOffset(row_offset); - return state_handler.flushPairAfterQuotedValue(file, pair_writer); - } - case State::WAITING_PAIR_DELIMITER: - { - return state_handler.waitPairDelimiter(file); - } - case State::END: - { - return {0, state}; - } - } - } - - void reset(auto & pair_writer) const - { - pair_writer.resetKey(); - pair_writer.resetValue(); - } - - const StateHandler state_handler; - const uint64_t max_number_of_pairs; -}; - -} - -struct KeyValuePairExtractorNoEscaping : extractKV::KeyValuePairExtractor -{ - using StateHandler = extractKV::NoEscapingStateHandler; - explicit KeyValuePairExtractorNoEscaping(const extractKV::Configuration & configuration_, std::size_t max_number_of_pairs_) - : KeyValuePairExtractor(configuration_, max_number_of_pairs_) {} - - uint64_t extract(std::string_view data, ColumnString::MutablePtr & keys, ColumnString::MutablePtr & values) const - { - auto pair_writer = typename StateHandler::PairWriter(*keys, *values); - return extractImpl(data, pair_writer); - } -}; - -struct KeyValuePairExtractorInlineEscaping : extractKV::KeyValuePairExtractor -{ - using StateHandler = extractKV::InlineEscapingStateHandler; - explicit KeyValuePairExtractorInlineEscaping(const extractKV::Configuration & configuration_, std::size_t max_number_of_pairs_) - : KeyValuePairExtractor(configuration_, max_number_of_pairs_) {} - - uint64_t extract(std::string_view data, ColumnString::MutablePtr & keys, ColumnString::MutablePtr & values) const - { - auto pair_writer = typename StateHandler::PairWriter(*keys, *values); - return extractImpl(data, pair_writer); - } -}; - -struct KeyValuePairExtractorReferenceMap : extractKV::KeyValuePairExtractor -{ - using StateHandler = extractKV::ReferencesMapStateHandler; - explicit KeyValuePairExtractorReferenceMap(const extractKV::Configuration & configuration_, std::size_t max_number_of_pairs_) - : KeyValuePairExtractor(configuration_, max_number_of_pairs_) {} - - uint64_t extract(std::string_view data, MapWithMemoryTracking & map) const - { - auto pair_writer = typename StateHandler::PairWriter(map); - return extractImpl(data, pair_writer); - } -}; - -} diff --git a/src/Functions/keyvaluepair/impl/CMakeLists.txt b/src/Functions/keyvaluepair/impl/CMakeLists.txt deleted file mode 100644 index c61fb520fcdf..000000000000 --- a/src/Functions/keyvaluepair/impl/CMakeLists.txt +++ /dev/null @@ -1,7 +0,0 @@ -include("${ClickHouse_SOURCE_DIR}/cmake/dbms_glob_sources.cmake") -add_headers_and_sources(clickhouse_functions_extractkeyvaluepairs_core .) -add_headers_and_sources(clickhouse_functions_extractkeyvaluepairs_core impl) - -add_library(clickhouse_functions_extractkeyvaluepairs_core ${clickhouse_functions_extractkeyvaluepairs_core_sources} ${clickhouse_functions_extractkeyvaluepairs_core_headers}) - -target_link_libraries(clickhouse_functions_extractkeyvaluepairs_core PRIVATE dbms) diff --git a/src/Functions/keyvaluepair/impl/Configuration.cpp b/src/Functions/keyvaluepair/impl/Configuration.cpp deleted file mode 100644 index 2fd111f316c4..000000000000 --- a/src/Functions/keyvaluepair/impl/Configuration.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include - -#include - -namespace DB -{ - -namespace ErrorCodes -{ - extern const int BAD_ARGUMENTS; -} - -namespace extractKV -{ - -Configuration::Configuration( - char key_value_delimiter_, - char quoting_character_, - VectorWithMemoryTracking pair_delimiters_, - UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy_) - : key_value_delimiter(key_value_delimiter_), - quoting_character(quoting_character_), - pair_delimiters(std::move(pair_delimiters_)), - unexpected_quoting_character_strategy(unexpected_quoting_character_strategy_) -{ -} - -Configuration ConfigurationFactory::createWithoutEscaping( - char key_value_delimiter, - char quoting_character, - VectorWithMemoryTracking pair_delimiters, - Configuration::UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy) -{ - validate(key_value_delimiter, quoting_character, pair_delimiters); - - return Configuration( - key_value_delimiter, - quoting_character, - pair_delimiters, - unexpected_quoting_character_strategy); -} - -Configuration ConfigurationFactory::createWithEscaping( - char key_value_delimiter, - char quoting_character, - VectorWithMemoryTracking pair_delimiters, - Configuration::UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy) -{ - static constexpr char ESCAPE_CHARACTER = '\\'; - - if (key_value_delimiter == ESCAPE_CHARACTER - || quoting_character == ESCAPE_CHARACTER - || std::find(pair_delimiters.begin(), pair_delimiters.end(), ESCAPE_CHARACTER) != pair_delimiters.end()) - { - throw Exception( - ErrorCodes::BAD_ARGUMENTS, - "Invalid arguments, {} is reserved for the escaping character", - ESCAPE_CHARACTER); - } - - return createWithoutEscaping( - key_value_delimiter, - quoting_character, - pair_delimiters, - unexpected_quoting_character_strategy); -} - -void ConfigurationFactory::validate(char key_value_delimiter, char quoting_character, VectorWithMemoryTracking pair_delimiters) -{ - if (key_value_delimiter == quoting_character) - { - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, key_value_delimiter and quoting_character can not be the same"); - } - - if (pair_delimiters.size() > MAX_NUMBER_OF_PAIR_DELIMITERS) - { - // SSE optimizations require needles to contain up to 16 characters. Needles can be a concatenation of multiple parameters, including - // quoting_character, key_value_delimiter and pair delimiters. Limiting to 8 to be on the safe side. - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, pair delimiters can contain at most {} characters", MAX_NUMBER_OF_PAIR_DELIMITERS); - } - - if (pair_delimiters.empty()) - { - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, pair delimiters list is empty"); - } - - bool is_key_value_delimiter_in_pair_delimiters - = std::find(pair_delimiters.begin(), pair_delimiters.end(), key_value_delimiter) != pair_delimiters.end(); - - if (is_key_value_delimiter_in_pair_delimiters) - { - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, key_value_delimiter conflicts with pair delimiters"); - } - - bool is_quoting_character_in_pair_delimiters - = std::find(pair_delimiters.begin(), pair_delimiters.end(), quoting_character) != pair_delimiters.end(); - - if (is_quoting_character_in_pair_delimiters) - { - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, quoting_character conflicts with pair delimiters"); - } -} - -} - -} diff --git a/src/Functions/keyvaluepair/impl/Configuration.h b/src/Functions/keyvaluepair/impl/Configuration.h deleted file mode 100644 index e9b2af900c08..000000000000 --- a/src/Functions/keyvaluepair/impl/Configuration.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include - -#include - -namespace DB -{ -namespace extractKV -{ -struct ConfigurationFactory; - -class Configuration -{ -public: - enum class UnexpectedQuotingCharacterStrategy - { - INVALID, - ACCEPT, - PROMOTE - }; - - const char key_value_delimiter; - const char quoting_character; - const VectorWithMemoryTracking pair_delimiters; - const UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy; - -private: - friend struct ConfigurationFactory; - - Configuration( - char key_value_delimiter_, - char quoting_character_, - VectorWithMemoryTracking pair_delimiters_, - UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy_ - ); -}; - -/* - * Validates (business logic) and creates Configurations for key-value-pair extraction. - * */ -struct ConfigurationFactory -{ -public: - static Configuration createWithoutEscaping(char key_value_delimiter, char quoting_character, VectorWithMemoryTracking pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy); - - static Configuration createWithEscaping(char key_value_delimiter, char quoting_character, VectorWithMemoryTracking pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy); - -private: - static void validate(char key_value_delimiter, char quoting_character, VectorWithMemoryTracking pair_delimiters); - - static constexpr auto MAX_NUMBER_OF_PAIR_DELIMITERS = 8u; -}; -} - -} diff --git a/src/Functions/keyvaluepair/impl/DuplicateKeyFoundException.h b/src/Functions/keyvaluepair/impl/DuplicateKeyFoundException.h deleted file mode 100644 index b7d1cc5fb4a8..000000000000 --- a/src/Functions/keyvaluepair/impl/DuplicateKeyFoundException.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include - -namespace DB -{ - -namespace extractKV -{ - -struct DuplicateKeyFoundException : Exception -{ - explicit DuplicateKeyFoundException(std::string_view key_) : key(key_) {} - - std::string_view key; -}; - -} - -} diff --git a/src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.cpp b/src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.cpp deleted file mode 100644 index ff020c2a70a5..000000000000 --- a/src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.cpp +++ /dev/null @@ -1,39 +0,0 @@ -#include - -#include - -namespace DB -{ - -KeyValuePairExtractorBuilder & KeyValuePairExtractorBuilder::withKeyValueDelimiter(char key_value_delimiter_) -{ - key_value_delimiter = key_value_delimiter_; - return *this; -} - -KeyValuePairExtractorBuilder & KeyValuePairExtractorBuilder::withItemDelimiters(VectorWithMemoryTracking item_delimiters_) -{ - item_delimiters = std::move(item_delimiters_); - return *this; -} - -KeyValuePairExtractorBuilder & KeyValuePairExtractorBuilder::withQuotingCharacter(char quoting_character_) -{ - quoting_character = quoting_character_; - return *this; -} - -KeyValuePairExtractorBuilder & KeyValuePairExtractorBuilder::withMaxNumberOfPairs(uint64_t max_number_of_pairs_) -{ - max_number_of_pairs = max_number_of_pairs_; - return *this; -} - -KeyValuePairExtractorBuilder & KeyValuePairExtractorBuilder::withUnexpectedQuotingCharacterStrategy( - extractKV::Configuration::UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy_) -{ - unexpected_quoting_character_strategy = unexpected_quoting_character_strategy_; - return *this; -} - -} diff --git a/src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.h b/src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.h deleted file mode 100644 index b4faf153c7ae..000000000000 --- a/src/Functions/keyvaluepair/impl/KeyValuePairExtractorBuilder.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace DB -{ - -struct KeyValuePairExtractor; - -class KeyValuePairExtractorBuilder -{ -public: - - KeyValuePairExtractorBuilder & withKeyValueDelimiter(char key_value_delimiter_); - - KeyValuePairExtractorBuilder & withItemDelimiters(VectorWithMemoryTracking item_delimiters_); - - KeyValuePairExtractorBuilder & withQuotingCharacter(char quoting_character_); - - KeyValuePairExtractorBuilder & withMaxNumberOfPairs(uint64_t max_number_of_pairs_); - - KeyValuePairExtractorBuilder & withUnexpectedQuotingCharacterStrategy(extractKV::Configuration::UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy_); - - auto buildWithoutEscaping() const - { - auto configuration = extractKV::ConfigurationFactory::createWithoutEscaping(key_value_delimiter, quoting_character, item_delimiters, unexpected_quoting_character_strategy); - - return KeyValuePairExtractorNoEscaping(configuration, max_number_of_pairs); - } - - auto buildWithEscaping() const - { - auto configuration = extractKV::ConfigurationFactory::createWithEscaping(key_value_delimiter, quoting_character, item_delimiters, unexpected_quoting_character_strategy); - - return KeyValuePairExtractorInlineEscaping(configuration, max_number_of_pairs); - } - - auto buildWithReferenceMap() const - { - auto configuration = extractKV::ConfigurationFactory::createWithoutEscaping(key_value_delimiter, quoting_character, item_delimiters, unexpected_quoting_character_strategy); - - return KeyValuePairExtractorReferenceMap(configuration, max_number_of_pairs); - } - -private: - char key_value_delimiter = ':'; - char quoting_character = '"'; - VectorWithMemoryTracking item_delimiters = {' ', ',', ';'}; - uint64_t max_number_of_pairs = std::numeric_limits::max(); - - /// The ideal default behavior should be `UnexpectedQuotingCharacterStrategy::INVALID`, but to make it backwards compatible, we are leaving it as `PROMOTE` - extractKV::Configuration::UnexpectedQuotingCharacterStrategy unexpected_quoting_character_strategy = extractKV::Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE; -}; - -} diff --git a/src/Functions/keyvaluepair/impl/NeedleFactory.h b/src/Functions/keyvaluepair/impl/NeedleFactory.h deleted file mode 100644 index 49b56935545b..000000000000 --- a/src/Functions/keyvaluepair/impl/NeedleFactory.h +++ /dev/null @@ -1,118 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -namespace DB -{ - -namespace extractKV -{ - -/* - * `StateHandlerImpl` makes use of string search algorithms to find delimiters. This class creates the needles for each state - * based on the contents of `Configuration`. - * */ -template -class NeedleFactory -{ -public: - static SearchSymbols getWaitKeyNeedles(const Configuration & extractor_configuration) - { - const auto & [key_value_delimiter, quoting_character, pair_delimiters, _] - = extractor_configuration; - - VectorWithMemoryTracking needles; - - needles.push_back(key_value_delimiter); - - std::copy(pair_delimiters.begin(), pair_delimiters.end(), std::back_inserter(needles)); - - if constexpr (WITH_ESCAPING) - { - needles.push_back('\\'); - } - - return SearchSymbols {std::string{needles.data(), needles.size()}}; - } - - static SearchSymbols getReadKeyNeedles(const Configuration & extractor_configuration) - { - const auto & [key_value_delimiter, quoting_character, pair_delimiters, unexpected_quoting_character_strategy] - = extractor_configuration; - - VectorWithMemoryTracking needles; - - needles.push_back(key_value_delimiter); - - if (unexpected_quoting_character_strategy != Configuration::UnexpectedQuotingCharacterStrategy::ACCEPT) - { - needles.push_back(quoting_character); - } - - std::copy(pair_delimiters.begin(), pair_delimiters.end(), std::back_inserter(needles)); - - if constexpr (WITH_ESCAPING) - { - needles.push_back('\\'); - } - - return SearchSymbols {std::string{needles.data(), needles.size()}}; - } - - static SearchSymbols getReadValueNeedles(const Configuration & extractor_configuration) - { - const auto & [key_value_delimiter, quoting_character, pair_delimiters, unexpected_quoting_character_strategy] - = extractor_configuration; - - VectorWithMemoryTracking needles; - - if (unexpected_quoting_character_strategy != Configuration::UnexpectedQuotingCharacterStrategy::ACCEPT) - { - needles.push_back(quoting_character); - } - - std::copy(pair_delimiters.begin(), pair_delimiters.end(), std::back_inserter(needles)); - - if constexpr (WITH_ESCAPING) - { - needles.push_back('\\'); - } - - return SearchSymbols {std::string{needles.data(), needles.size()}}; - } - - static SearchSymbols getReadQuotedNeedles(const Configuration & extractor_configuration) - { - const auto quoting_character = extractor_configuration.quoting_character; - - VectorWithMemoryTracking needles; - - needles.push_back(quoting_character); - - if constexpr (WITH_ESCAPING) - { - needles.push_back('\\'); - } - - return SearchSymbols {std::string{needles.data(), needles.size()}}; - } - - static SearchSymbols getWaitPairDelimiterNeedles(const Configuration & extractor_configuration) - { - const auto & pair_delimiters = extractor_configuration.pair_delimiters; - - VectorWithMemoryTracking needles; - - std::copy(pair_delimiters.begin(), pair_delimiters.end(), std::back_inserter(needles)); - - return SearchSymbols {std::string{needles.data(), needles.size()}}; - } -}; - -} - -} diff --git a/src/Functions/keyvaluepair/impl/StateHandler.h b/src/Functions/keyvaluepair/impl/StateHandler.h deleted file mode 100644 index f102debec609..000000000000 --- a/src/Functions/keyvaluepair/impl/StateHandler.h +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include - - -namespace DB -{ - -namespace extractKV -{ - -class StateHandler -{ -public: - enum State - { - // Skip characters until it finds a valid first key character. Might jump to READING_KEY, READING_QUOTED_KEY or END. - WAITING_KEY, - // Tries to read a key. Might jump to WAITING_KEY, WAITING_VALUE or END. - READING_KEY, - // Tries to read a quoted key. Might jump to WAITING_KEY, READING_KV_DELIMITER or END. - READING_QUOTED_KEY, - // Tries to read the key value pair delimiter. Might jump to WAITING_KEY, WAITING_VALUE or END. - READING_KV_DELIMITER, - // Skip characters until it finds a valid first value character. Might jump to READING_QUOTED_VALUE or READING_VALUE. - WAITING_VALUE, - // Tries to read a value. Jumps to FLUSH_PAIR. - READING_VALUE, - // Tries to read a quoted value. Might jump to FLUSH_PAIR or END. - READING_QUOTED_VALUE, - // In this state, both key and value have already been collected and should be flushed. Might jump to WAITING_KEY or END. - FLUSH_PAIR, - // The `READING_QUOTED_VALUE` will assert the closing quoting character is found and then flush the pair. In this case, we should not - // move from `FLUSH_PAIR` directly to `WAITING_FOR_KEY` because a pair delimiter has not been found. Might jump to WAITING_FOR_PAIR_DELIMITER or END - FLUSH_PAIR_AFTER_QUOTED_VALUE, - // Might jump to WAITING_KEY or END. - WAITING_PAIR_DELIMITER, - END - }; - - struct NextState - { - std::size_t position_in_string; - State state; - }; - - StateHandler() = default; - StateHandler(const StateHandler &) = default; - - virtual ~StateHandler() = default; -}; - -} - -} diff --git a/src/Functions/keyvaluepair/impl/StateHandlerImpl.h b/src/Functions/keyvaluepair/impl/StateHandlerImpl.h deleted file mode 100644 index 39a1077e3b38..000000000000 --- a/src/Functions/keyvaluepair/impl/StateHandlerImpl.h +++ /dev/null @@ -1,744 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include - -#include -#include -#include - - -namespace DB -{ - -namespace ErrorCodes -{ - extern const int LOGICAL_ERROR; -} - -namespace extractKV -{ - - -/* - * Handles all states present in `StateHandler::State`, except for `END`. The description of each state responsibility can be found in - * `StateHandler::State`. Advanced & optimized string search algorithms are used to search for control characters and form key value pairs. - * Each method returns a `StateHandler::NextState` object which contains the next state itself and the number of characters consumed by the previous state. - * - * The class is templated with a boolean that controls escaping support. As of now, there are three specializations: - * - * `NoEscapingStateHandler` does not handle escape sequences and writes directly to the output columns. - * `InlineEscapingStateHandler` handles escape sequences, but pays the price of temporary buffers. - * `ReferencesMapStateHandler` does not handle escape sequences and only store references to the input data. Its output is a map and not a column. - * */ -template -class StateHandlerImpl : public StateHandler -{ -public: - /* SearchNeedles do not change throughout the algorithm. Therefore, they are created only once in the constructor - * to avoid unnecessary copies. - * */ - explicit StateHandlerImpl(Configuration configuration_) - : configuration(std::move(configuration_)) - , wait_key_needles(NeedleFactory::getWaitKeyNeedles(configuration)) - , read_key_needles(NeedleFactory::getReadKeyNeedles(configuration)) - , read_value_needles(NeedleFactory::getReadValueNeedles(configuration)) - , read_quoted_needles(NeedleFactory::getReadQuotedNeedles(configuration)) - , wait_pair_delimiter_needles(NeedleFactory::getWaitPairDelimiterNeedles(configuration)) - {} - - /* - * Find first character that is considered a valid key character and proceeds to READING_KEY like states. - * */ - [[nodiscard]] NextState waitKey(std::string_view file) const - { - if (const auto * p = find_first_not_symbols_or_null(file, wait_key_needles)) - { - const size_t character_position = p - file.data(); - if (isQuotingCharacter(*p)) - { - // +1 to skip quoting character - return {character_position + 1u, State::READING_QUOTED_KEY}; - } - - return {character_position, State::READING_KEY}; - } - - return {file.size(), State::END}; - } - - /* - * Find first delimiter of interest (`read_needles`). Valid symbols are either `key_value_delimiter` and `escape_character` if escaping - * support is on. If it finds a pair delimiter, it discards the key. - * */ - [[nodiscard]] NextState readKey(std::string_view file, auto & pair_writer) const - { - pair_writer.resetKey(); - - size_t pos = 0; - - while (const auto * p = find_first_symbols_or_null({file.begin() + pos, file.end()}, read_key_needles)) - { - auto character_position = p - file.data(); - size_t next_pos = character_position + 1u; - - if (WITH_ESCAPING && isEscapeCharacter(*p)) - { - if constexpr (WITH_ESCAPING) - { - auto [parsed_successfully, escape_sequence_length] = consumeWithEscapeSequence(file, pos, character_position, pair_writer); - next_pos = character_position + escape_sequence_length; - - if (!parsed_successfully) - { - return {next_pos, State::WAITING_KEY}; - } - } - } - else if (isKeyValueDelimiter(*p)) - { - pair_writer.appendKey(file.data() + pos, file.data() + character_position); - - return {next_pos, State::WAITING_VALUE}; - } - else if (isPairDelimiter(*p)) - { - return {next_pos, State::WAITING_KEY}; - } - else if (isQuotingCharacter(*p)) - { - switch (configuration.unexpected_quoting_character_strategy) - { - case Configuration::UnexpectedQuotingCharacterStrategy::INVALID: - return {next_pos, State::WAITING_KEY}; - case Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE: - return {next_pos, State::READING_QUOTED_KEY}; - case Configuration::UnexpectedQuotingCharacterStrategy::ACCEPT: - // The quoting character should not be added to the search symbols list in case strategy = Configuration::UnexpectedQuotingCharacterStrategy::ACCEPT - // See `NeedleFactory` - throw Exception(ErrorCodes::LOGICAL_ERROR, "Failed to handle unexpected quoting character. This is a bug"); - } - } - - pos = next_pos; - } - - return {file.size(), State::END}; - } - - /* - * Search for closing quoting character and process escape sequences along the way (if escaping support is turned on). - * */ - [[nodiscard]] NextState readQuotedKey(std::string_view file, auto & pair_writer) const - { - pair_writer.resetKey(); - - size_t pos = 0; - - while (const auto * p = find_first_symbols_or_null({file.begin() + pos, file.end()}, read_quoted_needles)) - { - size_t character_position = p - file.data(); - size_t next_pos = character_position + 1u; - - if (WITH_ESCAPING && isEscapeCharacter(*p)) - { - if constexpr (WITH_ESCAPING) - { - auto [parsed_successfully, escape_sequence_length] = consumeWithEscapeSequence(file, pos, character_position, pair_writer); - next_pos = character_position + escape_sequence_length; - - if (!parsed_successfully) - { - return {next_pos, State::WAITING_KEY}; - } - } - } - else if (isQuotingCharacter(*p)) - { - pair_writer.appendKey(file.data() + pos, file.data() + character_position); - - if (pair_writer.isKeyEmpty()) - { - return {next_pos, State::WAITING_KEY}; - } - - return {next_pos, State::READING_KV_DELIMITER}; - } - - pos = next_pos; - } - - return {file.size(), State::END}; - } - - /* - * Validate expected key-value-delimiter is in place. - * */ - [[nodiscard]] NextState readKeyValueDelimiter(std::string_view file) const - { - if (!file.empty()) - { - const auto current_character = file[0]; - - if (isKeyValueDelimiter(current_character)) - { - return {1, WAITING_VALUE}; - } - } - - return {0, State::WAITING_KEY}; - } - - /* - * Check if next character is a valid value character and jumps to read-like states. Caveat here is that a pair delimiter must also lead to - * read-like states because it indicates empty values. - * */ - [[nodiscard]] NextState waitValue(std::string_view file) const - { - size_t pos = 0; - - if (!file.empty()) - { - const auto current_character = file[pos]; - - if (isQuotingCharacter(current_character)) - { - return {pos + 1u, State::READING_QUOTED_VALUE}; - } - - if constexpr (WITH_ESCAPING) - { - if (isEscapeCharacter(current_character)) - { - return {pos, State::WAITING_KEY}; - } - } - } - - return {pos, State::READING_VALUE}; - } - - /* - * Finds next delimiter of interest (`read_needles`). Valid symbols are either `pair_delimiter` and `escape_character` if escaping - * support is on. If it finds a `key_value_delimiter`, it discards the value. - * */ - [[nodiscard]] NextState readValue(std::string_view file, auto & pair_writer) const - { - pair_writer.resetValue(); - - size_t pos = 0; - - while (const auto * p = find_first_symbols_or_null({file.begin() + pos, file.end()}, read_value_needles)) - { - const size_t character_position = p - file.data(); - size_t next_pos = character_position + 1u; - - if (WITH_ESCAPING && isEscapeCharacter(*p)) - { - if constexpr (WITH_ESCAPING) - { - auto [parsed_successfully, escape_sequence_length] = consumeWithEscapeSequence(file, pos, character_position, pair_writer); - next_pos = character_position + escape_sequence_length; - - if (!parsed_successfully) - { - // Perform best-effort parsing and ignore invalid escape sequences at the end - return {next_pos, State::FLUSH_PAIR}; - } - } - } - else if (isPairDelimiter(*p)) - { - pair_writer.appendValue(file.data() + pos, file.data() + character_position); - - return {next_pos, State::FLUSH_PAIR}; - } - else if (isQuotingCharacter(*p)) - { - switch (configuration.unexpected_quoting_character_strategy) - { - case Configuration::UnexpectedQuotingCharacterStrategy::INVALID: - return {next_pos, State::WAITING_KEY}; - case Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE: - return {next_pos, State::READING_QUOTED_VALUE}; - case Configuration::UnexpectedQuotingCharacterStrategy::ACCEPT: - // The quoting character should not be added to the search symbols list in case strategy = Configuration::UnexpectedQuotingCharacterStrategy::ACCEPT - // See `NeedleFactory` - throw Exception(ErrorCodes::LOGICAL_ERROR, "Failed to handle unexpected quoting character. This is a bug"); - } - } - - pos = next_pos; - } - - // Reached end of input, consume rest of the file as value and make sure KV pair is produced. - pair_writer.appendValue(file.data() + pos, file.data() + file.size()); - return {file.size(), State::FLUSH_PAIR}; - } - - /* - * Search for closing quoting character and process escape sequences along the way (if escaping support is turned on). - * */ - [[nodiscard]] NextState readQuotedValue(std::string_view file, auto & pair_writer) const - { - size_t pos = 0; - - pair_writer.resetValue(); - - while (const auto * p = find_first_symbols_or_null({file.begin() + pos, file.end()}, read_quoted_needles)) - { - const size_t character_position = p - file.data(); - size_t next_pos = character_position + 1u; - - if (WITH_ESCAPING && isEscapeCharacter(*p)) - { - if constexpr (WITH_ESCAPING) - { - auto [parsed_successfully, escape_sequence_length] = consumeWithEscapeSequence(file, pos, character_position, pair_writer); - next_pos = character_position + escape_sequence_length; - - if (!parsed_successfully) - { - return {next_pos, State::WAITING_KEY}; - } - } - } - else if (isQuotingCharacter(*p)) - { - pair_writer.appendValue(file.data() + pos, file.data() + character_position); - - return {next_pos, State::FLUSH_PAIR_AFTER_QUOTED_VALUE}; - } - - pos = next_pos; - } - - return {file.size(), State::END}; - } - - [[nodiscard]] NextState flushPair(std::string_view file, auto & pair_writer) const - { - pair_writer.commitKey(); - pair_writer.commitValue(); - - return {0, file.empty() ? State::END : State::WAITING_KEY}; - } - - [[nodiscard]] NextState flushPairAfterQuotedValue(std::string_view file, auto & pair_writer) const - { - pair_writer.commitKey(); - pair_writer.commitValue(); - - return {0, file.empty() ? State::END : State::WAITING_PAIR_DELIMITER}; - } - - [[nodiscard]] NextState waitPairDelimiter(std::string_view file) const - { - if (const auto * p = find_first_symbols_or_null(file, wait_pair_delimiter_needles)) - { - const size_t character_position = p - file.data(); - size_t next_pos = character_position + 1u; - - return {next_pos, State::WAITING_KEY}; - } - - return {file.size(), State::END}; - } - - const Configuration configuration; - -private: - const SearchSymbols wait_key_needles; - const SearchSymbols read_key_needles; - const SearchSymbols read_value_needles; - const SearchSymbols read_quoted_needles; - const SearchSymbols wait_pair_delimiter_needles; - - /* - * Helper method to copy bytes until `character_pos` and process possible escape sequence. Returns a pair containing a boolean - * that indicates success and a std::size_t that contains the number of bytes read/ consumed. - * */ - template - std::pair consumeWithEscapeSequence(std::string_view file, size_t start_pos, size_t character_pos, auto & output) const - { - std::string escaped_sequence; - DB::ReadBufferFromMemory buf(file.substr(character_pos)); - - if constexpr (isKey) - { - output.appendKey(file.data() + start_pos, file.data() + character_pos); - } - else - { - output.appendValue(file.data() + start_pos, file.data() + character_pos); - } - - if (DB::parseComplexEscapeSequence(escaped_sequence, buf)) - { - if constexpr (isKey) - { - output.appendKey(escaped_sequence); - } - else - { - output.appendValue(escaped_sequence); - } - - - return {true, buf.getPosition()}; - } - - return {false, buf.getPosition()}; - } - - bool isKeyValueDelimiter(char character) const - { - return configuration.key_value_delimiter == character; - } - - bool isPairDelimiter(char character) const - { - const auto & pair_delimiters = configuration.pair_delimiters; - return std::find(pair_delimiters.begin(), pair_delimiters.end(), character) != pair_delimiters.end(); - } - - bool isQuotingCharacter(char character) const - { - return configuration.quoting_character == character; - } - - bool isEscapeCharacter(char character) const - { - return character == '\\'; - } -}; - -struct NoEscapingStateHandler : public StateHandlerImpl -{ - /* - * View based PairWriter, no temporary copies are used. - * */ - class PairWriter - { - ColumnString & key_col; - ColumnString & value_col; - - std::string_view key; - std::string_view value; - - public: - PairWriter(ColumnString & key_col_, ColumnString & value_col_) - : key_col(key_col_), value_col(value_col_) - {} - - ~PairWriter() - { - // Make sure that ColumnString invariants are not broken. - if (!isKeyEmpty()) - { - resetKey(); - } - - if (!isValueEmpty()) - { - resetValue(); - } - } - - void appendKey(std::string_view new_data) - { - key = new_data; - } - - template - void appendKey(const T * begin, const T * end) - { - appendKey({begin, end}); - } - - void appendValue(std::string_view new_data) - { - value = new_data; - } - - template - void appendValue(const T * begin, const T * end) - { - appendValue({begin, end}); - } - - void resetKey() - { - key = {}; - } - - void resetValue() - { - value = {}; - } - - bool isKeyEmpty() const - { - return key.empty(); - } - - bool isValueEmpty() const - { - return value.empty(); - } - - void commitKey() - { - key_col.insertData(key.data(), key.size()); - resetKey(); - } - - void commitValue() - { - value_col.insertData(value.data(), value.size()); - resetValue(); - } - - - std::string_view uncommittedKeyChunk() const - { - return key; - } - - std::string_view uncommittedValueChunk() const - { - return value; - } - }; - - template - explicit NoEscapingStateHandler(Args && ... args) - : StateHandlerImpl(std::forward(args)...) {} -}; - -struct InlineEscapingStateHandler : public StateHandlerImpl -{ - class PairWriter - { - ColumnString & key_col; - ColumnString::Chars & key_chars; - UInt64 key_prev_commit_pos; - - ColumnString & value_col; - ColumnString::Chars & value_chars; - UInt64 value_prev_commit_pos; - - public: - PairWriter(ColumnString & key_col_, ColumnString & value_col_) - : key_col(key_col_), - key_chars(key_col.getChars()), - key_prev_commit_pos(key_chars.size()), - value_col(value_col_), - value_chars(value_col.getChars()), - value_prev_commit_pos(value_chars.size()) - {} - - ~PairWriter() - { - // Make sure that ColumnString invariants are not broken. - if (!isKeyEmpty()) - { - resetKey(); - } - - if (!isValueEmpty()) - { - resetValue(); - } - } - - void appendKey(std::string_view new_data) - { - key_chars.insert(new_data.begin(), new_data.end()); - } - - template - void appendKey(const T * begin, const T * end) - { - key_chars.insert(begin, end); - } - - void appendValue(std::string_view new_data) - { - value_chars.insert(new_data.begin(), new_data.end()); - } - - template - void appendValue(const T * begin, const T * end) - { - value_chars.insert(begin, end); - } - - void resetKey() - { - key_chars.resize_assume_reserved(key_prev_commit_pos); - } - - void resetValue() - { - value_chars.resize_assume_reserved(value_prev_commit_pos); - } - - bool isKeyEmpty() const - { - return key_chars.size() == key_prev_commit_pos; - } - - bool isValueEmpty() const - { - return value_chars.size() == value_prev_commit_pos; - } - - void commitKey() - { - key_col.insertData(nullptr, 0); - key_prev_commit_pos = key_chars.size(); - } - - void commitValue() - { - value_col.insertData(nullptr, 0); - value_prev_commit_pos = value_chars.size(); - } - - std::string_view uncommittedKeyChunk() const - { - return std::string_view(key_chars.raw_data() + key_prev_commit_pos, key_chars.raw_data() + key_chars.size()); - } - - std::string_view uncommittedValueChunk() const - { - return std::string_view(value_chars.raw_data() + value_prev_commit_pos, value_chars.raw_data() + value_chars.size()); - } - }; - - template - explicit InlineEscapingStateHandler(Args && ... args) - : StateHandlerImpl(std::forward(args)...) {} -}; - -struct ReferencesMapStateHandler : public StateHandlerImpl -{ - /* - * View based PairWriter, no copies at all - * */ - class PairWriter - { - MapWithMemoryTracking & map; - - std::string_view key; - std::string_view value; - - public: - explicit PairWriter(MapWithMemoryTracking & map_) - : map(map_) - {} - - ~PairWriter() - { - // Make sure that ColumnString invariants are not broken. - if (!isKeyEmpty()) - { - resetKey(); - } - - if (!isValueEmpty()) - { - resetValue(); - } - } - - void appendKey(std::string_view new_data) - { - key = new_data; - } - - template - void appendKey(const T * begin, const T * end) - { - appendKey({begin, end}); - } - - void appendValue(std::string_view new_data) - { - value = new_data; - } - - template - void appendValue(const T * begin, const T * end) - { - appendValue({begin, end}); - } - - void resetKey() - { - key = {}; - } - - void resetValue() - { - value = {}; - } - - bool isKeyEmpty() const - { - return key.empty(); - } - - bool isValueEmpty() const - { - return value.empty(); - } - - void commitKey() - { - // don't do anything - } - - void commitValue() - { - if (map.contains(key) && value != map[key]) - { - throw DuplicateKeyFoundException(key); - } - - map[key] = value; - - resetValue(); - resetKey(); - } - - std::string_view uncommittedKeyChunk() const - { - return key; - } - - std::string_view uncommittedValueChunk() const - { - return value; - } - }; - - template - explicit ReferencesMapStateHandler(Args && ... args) - : StateHandlerImpl(std::forward(args)...) {} -}; - - -} - -} diff --git a/src/Functions/keyvaluepair/tests/gtest_escaping_key_value_pair_extractor.cpp b/src/Functions/keyvaluepair/tests/gtest_escaping_key_value_pair_extractor.cpp deleted file mode 100644 index 1e8e71201bfa..000000000000 --- a/src/Functions/keyvaluepair/tests/gtest_escaping_key_value_pair_extractor.cpp +++ /dev/null @@ -1,39 +0,0 @@ -#include - -#include - -#include -#include - -namespace DB -{ - -static void assert_byte_equality(std::string_view lhs, const std::vector & rhs) -{ - std::vector lhs_vector {lhs.data(), lhs.data() + lhs.size()}; - ASSERT_EQ(lhs_vector, rhs); -} - -TEST(extractKVPairEscapingKeyValuePairExtractor, EscapeSequences) -{ - using namespace std::literals; - - auto extractor = KeyValuePairExtractorBuilder().buildWithEscaping(); - - auto keys = ColumnString::create(); - auto values = ColumnString::create(); - - auto pairs_count = extractor.extract(R"(key1:a\xFF key2:a\n\t\r)"sv, keys, values); - - ASSERT_EQ(pairs_count, 2u); - ASSERT_EQ(keys->size(), pairs_count); - ASSERT_EQ(keys->size(), values->size()); - - ASSERT_EQ(keys->getDataAt(0), "key1"); - ASSERT_EQ(keys->getDataAt(1), "key2"); - - assert_byte_equality(values->getDataAt(0), {'a', 0xFF}); - assert_byte_equality(values->getDataAt(1), {'a', 0xA, 0x9, 0xD}); -} - -} diff --git a/src/Functions/keyvaluepair/tests/gtest_extractKeyValuePairs.cpp b/src/Functions/keyvaluepair/tests/gtest_extractKeyValuePairs.cpp deleted file mode 100644 index bac3e2397385..000000000000 --- a/src/Functions/keyvaluepair/tests/gtest_extractKeyValuePairs.cpp +++ /dev/null @@ -1,173 +0,0 @@ -#include -#include - -#include -#include -#include -#include -#include - -#include -#include - -#include -#include - - -namespace -{ -using namespace DB; -using namespace std::literals; - -// Print as a map with a single row -auto ToColumnMap(const auto & keys, const auto & values, const ColumnPtr offsets = nullptr) -{ - return ColumnMap::create( - std::move(keys->clone()), - std::move(values->clone()), - offsets ? offsets : ColumnUInt64::create(1, keys->size()) - ); -} - -// Print as a map with a single row -std::string PrintMap(const auto & keys, const auto & values) -{ - auto map_column = ToColumnMap(keys, values); - auto serialization = DataTypeFactory::instance().get("Map(String, String)")->getDefaultSerialization(); - - WriteBufferFromOwnString buff; - serialization->serializeTextJSON(*map_column, 0, buff, FormatSettings{}); - - return std::move(buff.str()); -} - -} - -struct KeyValuePairExtractorTestParam -{ - KeyValuePairExtractorBuilder builder; - std::string input; - std::vector> expected; - bool use_escaping = false; -}; - -struct extractKVPairKeyValuePairExtractorTest : public ::testing::TestWithParam -{}; - -TEST_P(extractKVPairKeyValuePairExtractorTest, Match) -{ - const auto & [builder, input, expected, use_escaping] = GetParam(); - SCOPED_TRACE(input); - - auto keys = ColumnString::create(); - auto values = ColumnString::create(); - - std::size_t pairs_found = 0; - - if (use_escaping) - { - auto kv_parser = builder.buildWithEscaping(); - SCOPED_TRACE(typeid(kv_parser).name()); - - pairs_found = kv_parser.extract(input, keys, values); - } - else - { - auto kv_parser = builder.buildWithoutEscaping(); - SCOPED_TRACE(typeid(kv_parser).name()); - - pairs_found = kv_parser.extract(input, keys, values); - } - - ASSERT_EQ(expected.size(), pairs_found); - - size_t i = 0; - for (const auto & expected_kv : expected) - { - EXPECT_EQ(expected_kv.first, keys->getDataAt(i)); - - EXPECT_EQ(expected_kv.second, values->getDataAt(i)); - - ++i; - } -} - -using ExpectedValues = std::vector>; -const ExpectedValues neymar_expected{ - {"name","neymar"}, - {"age","31"}, - {"team","psg"}, - {"nationality","brazil"}, - {"last_key","last_value"} -}; - -INSTANTIATE_TEST_SUITE_P(Simple, extractKVPairKeyValuePairExtractorTest, - ::testing::ValuesIn(std::initializer_list - { - { - KeyValuePairExtractorBuilder().withQuotingCharacter('\''), - R"in(name:'neymar';'age':31;team:psg;nationality:brazil,last_key:last_value)in", - neymar_expected - }, - { - // Different escaping char - KeyValuePairExtractorBuilder().withQuotingCharacter('"'), - R"in(name:"neymar";"age":31;team:psg;nationality:brazil,last_key:last_value)in", - neymar_expected - }, - { - // same as case 1, but with another handler - KeyValuePairExtractorBuilder().withQuotingCharacter('\''), - R"in(name:'neymar';'age':31;team:psg;nationality:brazil,last_key:last_value)in", - neymar_expected, - true - } - } - ) -); - -// Perform best-effort parsing for invalid escape sequences -INSTANTIATE_TEST_SUITE_P(InvalidEscapeSeqInValue, extractKVPairKeyValuePairExtractorTest, - ::testing::ValuesIn(std::initializer_list - { - { - // Special case when invalid seq is the last symbol - KeyValuePairExtractorBuilder(), - R"in(valid_key:valid_value key:invalid_val\)in", - ExpectedValues{ - {"valid_key", "valid_value"}, - {"key", "invalid_val"} - }, - true - }, - // Not handling escape sequences == do not care of broken one, `invalid_val\` must be present - { - KeyValuePairExtractorBuilder(), - R"in(valid_key:valid_value key:invalid_val\ third_key:third_value)in", - ExpectedValues{ - {"valid_key", "valid_value"}, - {"key", "invalid_val\\"}, - {"third_key", "third_value"} - } - }, - { - // Special case when invalid seq is the last symbol - KeyValuePairExtractorBuilder(), - R"in(valid_key:valid_value key:invalid_val\)in", - ExpectedValues{ - {"valid_key", "valid_value"}, - {"key", "invalid_val\\"} - } - }, - { - KeyValuePairExtractorBuilder().withQuotingCharacter('"'), - R"in(valid_key:valid_value key:"invalid val\ " "third key":"third value")in", - ExpectedValues{ - {"valid_key", "valid_value"}, - {"key", "invalid val\\ "}, - {"third key", "third value"}, - } - }, - } - ) -); diff --git a/src/Functions/keyvaluepair/tests/gtest_inline_escaping_key_state_handler.cpp b/src/Functions/keyvaluepair/tests/gtest_inline_escaping_key_state_handler.cpp deleted file mode 100644 index eb31935df552..000000000000 --- a/src/Functions/keyvaluepair/tests/gtest_inline_escaping_key_state_handler.cpp +++ /dev/null @@ -1,121 +0,0 @@ -#include -#include - - -#include -#include - -namespace -{ - -using namespace DB; -using namespace DB::extractKV; - -using State = extractKV::StateHandler::State; -using NextState = extractKV::StateHandler::NextState; - -void test_wait(const auto & handler, std::string_view input, std::size_t expected_pos, State expected_state) -{ - auto next_state = handler.waitKey(input); - - ASSERT_EQ(next_state.position_in_string, expected_pos); - ASSERT_EQ(next_state.state, expected_state); -} - -template -void test_read(const auto & handler, std::string_view input, std::string_view expected_element, - std::size_t expected_pos, State expected_state) -{ - auto str = ColumnString::create(); - auto val = ColumnString::create(); - NextState next_state; - InlineEscapingStateHandler::PairWriter element(*str, *val); - - if constexpr (quoted) - { - next_state = handler.readQuotedKey(input, element); - } - else - { - next_state = handler.readKey(input, element); - } - - ASSERT_EQ(next_state.position_in_string, expected_pos); - ASSERT_EQ(next_state.state, expected_state); - ASSERT_EQ(element.uncommittedKeyChunk(), expected_element); -} - -void test_read(const auto & handler, std::string_view input, std::string_view expected_element, - std::size_t expected_pos, State expected_state) -{ - test_read(handler, input, expected_element, expected_pos, expected_state); -} - -void test_read_quoted(const auto & handler, std::string_view input, std::string_view expected_element, - std::size_t expected_pos, State expected_state) -{ - test_read(handler, input, expected_element, expected_pos, expected_state); -} - -} - -TEST(extractKVPairInlineEscapingKeyStateHandler, Wait) -{ - auto pair_delimiters = VectorWithMemoryTracking{',', ' '}; - - auto configuration = ConfigurationFactory::createWithEscaping(':', '"', pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE); - - StateHandlerImpl handler(configuration); - - test_wait(handler, "name", 0u, State::READING_KEY); - test_wait(handler, "\\:name", 2u, State::READING_KEY); - test_wait(handler, R"(\\"name)", 3u, State::READING_QUOTED_KEY); - - test_wait(handler, "", 0u, State::END); - test_wait(handler, "\\\\", 2u, State::END); -} - -TEST(extractKVPairInlineEscapingKeyStateHandler, Read) -{ - auto pair_delimiters = VectorWithMemoryTracking{',', ' '}; - - auto configuration = ConfigurationFactory::createWithEscaping(':', '"', pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE); - - StateHandlerImpl handler(configuration); - - std::string key_str = "name"; - std::string key_with_delimiter_str = key_str + ':'; - std::string key_with_delimiter_and_random_characters_str = key_str + ':' + "a$a\\:''\""; - - // no delimiter, should discard - test_read(handler, key_str, "", key_str.size(), State::END); - - // valid - test_read(handler, key_with_delimiter_str, key_str, key_with_delimiter_str.size(), State::WAITING_VALUE); - - // valid as well - test_read(handler, key_with_delimiter_and_random_characters_str, key_str, key_with_delimiter_str.size(), State::WAITING_VALUE); - - test_read(handler, "", "", 0u, State::END); -} - -TEST(extractKVPairInlineEscapingKeyStateHandler, ReadEnclosed) -{ - auto pair_delimiters = VectorWithMemoryTracking{',', ' '}; - - auto configuration = ConfigurationFactory::createWithEscaping(':', '"', pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE); - - StateHandlerImpl handler(configuration); - - std::string regular_key = "name"; - std::string regular_key_with_end_quote = regular_key + "\""; - std::string key_with_special_characters = "name $!@#¨%&*%&%.569-519"; - std::string key_with_special_characters_with_end_quote = "name $!@#¨%&*%&%.569-519\""; - - std::string key_with_escape_character = regular_key + R"(\n\x4E")"; - - test_read_quoted(handler, regular_key, "", regular_key.size(), State::END); - test_read_quoted(handler, regular_key_with_end_quote, regular_key, regular_key_with_end_quote.size(), State::READING_KV_DELIMITER); - test_read_quoted(handler, key_with_special_characters_with_end_quote, key_with_special_characters, key_with_special_characters_with_end_quote.size(), State::READING_KV_DELIMITER); - test_read_quoted(handler, key_with_escape_character, regular_key + "\nN", key_with_escape_character.size(), State::READING_KV_DELIMITER); -} diff --git a/src/Functions/keyvaluepair/tests/gtest_inline_escaping_value_state_handler.cpp b/src/Functions/keyvaluepair/tests/gtest_inline_escaping_value_state_handler.cpp deleted file mode 100644 index 819a916c5552..000000000000 --- a/src/Functions/keyvaluepair/tests/gtest_inline_escaping_value_state_handler.cpp +++ /dev/null @@ -1,34 +0,0 @@ -#include - -#include -#include - -namespace -{ - -using namespace DB; -using namespace DB::extractKV; - -using State = extractKV::StateHandler::State; -using NextState = extractKV::StateHandler::NextState; - - -void test_wait(const auto & handler, std::string_view input, std::size_t expected_pos, State expected_state) -{ - auto next_state = handler.waitValue(input); - - ASSERT_EQ(next_state.position_in_string, expected_pos); - ASSERT_EQ(next_state.state, expected_state); -} - -} - -TEST(extractKVPairInlineEscapingValueStateHandler, Wait) -{ - auto pair_delimiters = VectorWithMemoryTracking {','}; - - auto configuration = ConfigurationFactory::createWithEscaping(':', '"', pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE); - StateHandlerImpl handler(configuration); - - test_wait(handler, " los$ yours3lf", 0u, State::READING_VALUE); -} diff --git a/src/Functions/keyvaluepair/tests/gtest_no_escaping_key_state_handler.cpp b/src/Functions/keyvaluepair/tests/gtest_no_escaping_key_state_handler.cpp deleted file mode 100644 index 68f2e66ee7c7..000000000000 --- a/src/Functions/keyvaluepair/tests/gtest_no_escaping_key_state_handler.cpp +++ /dev/null @@ -1,103 +0,0 @@ -#include -#include - -#include - -#include - -namespace -{ -using namespace DB; -using namespace DB::extractKV; - -using State = extractKV::StateHandler::State; -using NextState = extractKV::StateHandler::NextState; - -void test_wait(const auto & handler, std::string_view input, std::size_t expected_pos, State expected_state) -{ - auto next_state = handler.waitKey(input); - - ASSERT_EQ(next_state.position_in_string, expected_pos); - ASSERT_EQ(next_state.state, expected_state); -} - -template -void test_read(const auto & handler, std::string_view input, std::string_view expected_element, - std::size_t expected_pos, State expected_state) -{ - NextState next_state; - - auto key = ColumnString::create(); - auto val = ColumnString::create(); - NoEscapingStateHandler::PairWriter element(*key, *val); - - if constexpr (quoted) - { - next_state = handler.readQuotedKey(input, element); - } - else - { - next_state = handler.readKey(input, element); - } - - ASSERT_EQ(next_state.position_in_string, expected_pos); - ASSERT_EQ(next_state.state, expected_state); - ASSERT_EQ(element.uncommittedKeyChunk(), expected_element); -} - -void test_read(const auto & handler, std::string_view input, std::string_view expected_element, - std::size_t expected_pos, State expected_state) -{ - test_read(handler, input, expected_element, expected_pos, expected_state); -} - -void test_read_quoted(const auto & handler, std::string_view input, std::string_view expected_element, - std::size_t expected_pos, State expected_state) -{ - test_read(handler, input, expected_element, expected_pos, expected_state); -} - -} - -TEST(extractKVPairNoEscapingKeyStateHandler, Wait) -{ - auto pair_delimiters = VectorWithMemoryTracking{',', ' ', '$'}; - - auto configuration = ConfigurationFactory::createWithEscaping(':', '"', pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE); - - NoEscapingStateHandler handler(configuration); - - test_wait(handler, "name", 0u, State::READING_KEY); - test_wait(handler, "\\:name", 0u, State::READING_KEY); - // quoted expected pos is + 1 because as of now it is skipped, maybe I should change it - test_wait(handler, "\"name", 1u, State::READING_QUOTED_KEY); - - test_wait(handler, ", $name", 3u, State::READING_KEY); - test_wait(handler, ", $\"name", 4u, State::READING_QUOTED_KEY); - - test_wait(handler, "", 0u, State::END); -} - -TEST(extractKVPairNoEscapingKeyStateHandler, Read) -{ - auto pair_delimiters = VectorWithMemoryTracking{',', ' '}; - - auto configuration = ConfigurationFactory::createWithEscaping(':', '"', pair_delimiters, Configuration::UnexpectedQuotingCharacterStrategy::PROMOTE); - - NoEscapingStateHandler handler(configuration); - - std::string key_str = "name"; - std::string key_with_delimiter_str = key_str + ':'; - std::string key_with_delimiter_and_random_characters_str = key_str + ':' + "a$a\\:''\""; - - // no delimiter, should discard - test_read(handler, key_str, "", key_str.size(), State::END); - - // valid - test_read(handler, key_with_delimiter_str, key_str, key_with_delimiter_str.size(), State::WAITING_VALUE); - - // valid as well - test_read(handler, key_with_delimiter_and_random_characters_str, key_str, key_with_delimiter_str.size(), State::WAITING_VALUE); - - test_read(handler, "", "", 0u, State::END); -} diff --git a/src/Storages/HivePartitioningUtils.cpp b/src/Storages/HivePartitioningUtils.cpp index be72281a9ed6..36580f0a2e6d 100644 --- a/src/Storages/HivePartitioningUtils.cpp +++ b/src/Storages/HivePartitioningUtils.cpp @@ -6,8 +6,7 @@ #include #include #include -#include -#include +#include #include #include #include @@ -31,15 +30,14 @@ namespace ErrorCodes namespace HivePartitioningUtils { -static auto makeExtractor() +static const KeyValuePairExtractor & getExtractor() { - return KeyValuePairExtractorBuilder().withItemDelimiters({'/'}).withKeyValueDelimiter('=').buildWithReferenceMap(); + static const KeyValuePairExtractor extractor({.key_value_delimiter = '=', .pair_delimiters = "/"}); + return extractor; } HivePartitioningKeysAndValues parseHivePartitioningKeysAndValues(const String & path) { - static auto extractor = makeExtractor(); - HivePartitioningKeysAndValues key_values; // cutting the filename to prevent malformed filenames that contain key-value-pairs from being extracted @@ -54,14 +52,12 @@ HivePartitioningKeysAndValues parseHivePartitioningKeysAndValues(const String & std::string_view path_without_filename(path.data(), last_slash_pos); - try + getExtractor().forEachPair(path_without_filename, [&](std::string_view key, std::string_view value) { - extractor.extract(path_without_filename, key_values); - } - catch (const extractKV::DuplicateKeyFoundException & ex) - { - throw Exception(ErrorCodes::INCORRECT_DATA, "Path '{}' to file with enabled hive-style partitioning contains duplicated partition key {} with different values, only unique keys are allowed", path, ex.key); - } + auto [it, inserted] = key_values.try_emplace(key, value); + if (!inserted && it->second != value) + throw Exception(ErrorCodes::INCORRECT_DATA, "Path '{}' to file with enabled hive-style partitioning contains duplicated partition key {} with different values, only unique keys are allowed", path, key); + }); return key_values; } diff --git a/tests/performance/extract_key_value_pairs.xml b/tests/performance/extract_key_value_pairs.xml new file mode 100644 index 000000000000..e531cd9bb7ff --- /dev/null +++ b/tests/performance/extract_key_value_pairs.xml @@ -0,0 +1,57 @@ + + + + func + + extractKeyValuePairs + extractKeyValuePairsWithEscaping + + + + + + CREATE TABLE kv_short (s String) ENGINE = Memory + INSERT INTO kv_short SELECT concat('name:neymar', toString(number % 1000), ', age:31 team:psg,nationality:brazil, city:"Sao Paulo", id:', toString(number), ', extra:', repeat('x', 20)) FROM numbers(1000000) + + + CREATE TABLE kv_long_values (s String) ENGINE = Memory + INSERT INTO kv_long_values SELECT concat('k1:', repeat('a', 200), ' k2:', repeat('b', 200), ' k3:', toString(number)) FROM numbers(300000) + + + CREATE TABLE kv_very_long_values (s String) ENGINE = Memory + INSERT INTO kv_very_long_values SELECT concat('k1:', repeat('a', 2000), ' k2:"', repeat('b', 2000), '" k3:', toString(number)) FROM numbers(30000) + + + CREATE TABLE kv_log_lines (s String) ENGINE = Memory + INSERT INTO kv_log_lines SELECT concat('2024-01-01 12:00:00.123 [thread-', toString(number % 64), '] INFO Request processed user_id:', toString(number), ' method:GET path:/api/v1/items status:200 latency_ms:', toString(number % 500), ' ua:"Mozilla/5.0 (X11; Linux x86_64)" ok') FROM numbers(500000) + + + CREATE TABLE kv_many_tiny_pairs (s String) ENGINE = Memory + INSERT INTO kv_many_tiny_pairs SELECT arrayStringConcat(arrayMap(i -> concat('k', toString(i), ':', toString((number + i) % 10)), range(25)), ' ') FROM numbers(500000) + + + CREATE TABLE kv_no_pairs (s String) ENGINE = Memory + INSERT INTO kv_no_pairs SELECT repeat(char(97 + number % 26), 1000) FROM numbers(100000) + + + CREATE TABLE kv_escaped (s String) ENGINE = Memory + INSERT INTO kv_escaped SELECT concat('name:ney\\tmar', toString(number % 1000), ', path:C\\x3A\\\\Users\\\\', toString(number), ' text:"quoted \\"value\\" here", tab\\tkey:va\\nlue, id:', toString(number)) FROM numbers(1000000) + + SELECT count() FROM kv_short WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 + SELECT count() FROM kv_long_values WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 + SELECT count() FROM kv_very_long_values WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 + SELECT count() FROM kv_log_lines WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 + SELECT count() FROM kv_many_tiny_pairs WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 + SELECT count() FROM kv_no_pairs WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 + SELECT count() FROM kv_escaped WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 + SELECT count() FROM kv_short WHERE NOT ignore({func}(s, ':', ' ,;', '"', 'INVALID')) SETTINGS max_threads = 1 + SELECT count() FROM kv_short WHERE NOT ignore({func}(s, ':', ' ,;', '"', 'ACCEPT')) SETTINGS max_threads = 1 + + DROP TABLE IF EXISTS kv_short + DROP TABLE IF EXISTS kv_long_values + DROP TABLE IF EXISTS kv_very_long_values + DROP TABLE IF EXISTS kv_log_lines + DROP TABLE IF EXISTS kv_many_tiny_pairs + DROP TABLE IF EXISTS kv_no_pairs + DROP TABLE IF EXISTS kv_escaped + diff --git a/tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.reference b/tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.reference new file mode 100644 index 000000000000..47ebe9a88a93 --- /dev/null +++ b/tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.reference @@ -0,0 +1,126 @@ +-- { echoOn } + +-- standard escape sequences are decoded, the result is checked byte by byte +SELECT hex(x['key1']), hex(x['key2']) FROM (SELECT extractKeyValuePairsWithEscaping('key1:a\\xFF key2:a\\n\\t\\r') AS x); +61FF 610A090D +SELECT extractKeyValuePairsWithEscaping('age:a\\x0A\\n\\0'); +{'age':'a\n\n\0'} +SELECT extractKeyValuePairs('age:a\\x0A\\n\\0'); +{'age':'a\\x0A\\n\\0'} +-- quoting character inside a quoted value must be escaped +SELECT extractKeyValuePairsWithEscaping('a:"aaaa\\"bbb" c:d'); +{'a':'aaaa"bbb','c':'d'} +SELECT extractKeyValuePairs('a:"aaaa\\"bbb" c:d'); +{'a':'aaaa\\','c':'d'} +-- non-standard escape sequences keep the backslash +SELECT extractKeyValuePairsWithEscaping('ke\\y:va\\lue k\\x41:v'); +{'ke\\y':'va\\lue','kA':'v'} +-- invalid escape sequence at the end of the value: best effort, the value is kept +SELECT extractKeyValuePairsWithEscaping('valid_key:valid_value key:invalid_val\\'); +{'valid_key':'valid_value','key':'invalid_val'} +SELECT extractKeyValuePairs('valid_key:valid_value key:invalid_val\\'); +{'valid_key':'valid_value','key':'invalid_val\\'} +SELECT extractKeyValuePairsWithEscaping('valid_key:valid_value key:invalid_val\\ third_key:third_value'); +{'valid_key':'valid_value','key':'invalid_val\\ third_key:third_value'} +SELECT extractKeyValuePairs('valid_key:valid_value key:invalid_val\\ third_key:third_value'); +{'valid_key':'valid_value','key':'invalid_val\\','third_key':'third_value'} +SELECT extractKeyValuePairsWithEscaping('valid_key:valid_value key:"invalid val\\ " "third key":"third value"'); +{'valid_key':'valid_value','key':'invalid val\\ ','third key':'third value'} +SELECT extractKeyValuePairs('valid_key:valid_value key:"invalid val\\ " "third key":"third value"'); +{'valid_key':'valid_value','key':'invalid val\\ ','third key':'third value'} +-- incomplete hex escape sequence +SELECT extractKeyValuePairsWithEscaping('k\\x4'); +{} +SELECT extractKeyValuePairsWithEscaping('k\\x4:v a:b'); +{'a':'b'} +SELECT extractKeyValuePairsWithEscaping('a:v\\x4'); +{'a':'v'} +SELECT extractKeyValuePairsWithEscaping('a:"v\\x4" b:c'); +{} +-- leading escape sequences are skipped in keys and are invalid for values +SELECT extractKeyValuePairsWithEscaping('\\:name:val'); +{'name':'val'} +SELECT extractKeyValuePairsWithEscaping('\\\\"name":val'); +{'name':'val'} +SELECT extractKeyValuePairsWithEscaping('a:\\N b:c'); +{'b':'c'} +SELECT extractKeyValuePairsWithEscaping('a:x\\N b:c'); +{'a':'x','b':'c'} +SELECT extractKeyValuePairsWithEscaping('"\\N":5 a:b'); +{'a':'b'} +-- escape sequences inside quoted keys +SELECT extractKeyValuePairsWithEscaping('"name\\n\\x4E":1'); +{'name\nN':'1'} +SELECT hex(x['name\nN']) FROM (SELECT extractKeyValuePairsWithEscaping('"name\\n\\x4E":1') AS x); +31 +-- single quote as quoting character, both variants +SELECT extractKeyValuePairs('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\''); +{'name':'neymar','age':'31','team':'psg','nationality':'brazil','last_key':'last_value'} +SELECT extractKeyValuePairsWithEscaping('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\''); +{'name':'neymar','age':'31','team':'psg','nationality':'brazil','last_key':'last_value'} +-- unexpected quoting character strategies with escaping +SELECT extractKeyValuePairsWithEscaping('name"abc:5', ':', ' ,;', '"', 'INVALID'); +{'abc':'5'} +SELECT extractKeyValuePairsWithEscaping('name"abc":5', ':', ' ,;', '"', 'INVALID'); +{} +SELECT extractKeyValuePairsWithEscaping('name"abc:5', ':', ' ,;', '"', 'ACCEPT'); +{'name"abc':'5'} +SELECT extractKeyValuePairsWithEscaping('name"abc":5', ':', ' ,;', '"', 'ACCEPT'); +{'name"abc"':'5'} +SELECT extractKeyValuePairsWithEscaping('name"abc:5', ':', ' ,;', '"', 'PROMOTE'); +{} +SELECT extractKeyValuePairsWithEscaping('name"abc":5', ':', ' ,;', '"', 'PROMOTE'); +{'abc':'5'} +SELECT extractKeyValuePairsWithEscaping('k:v"x y:z', ':', ' ,;', '"', 'INVALID'); +{'y':'z'} +SELECT extractKeyValuePairsWithEscaping('k:v"x y:z', ':', ' ,;', '"', 'ACCEPT'); +{'k':'v"x','y':'z'} +SELECT extractKeyValuePairsWithEscaping('k:v"x y:z', ':', ' ,;', '"', 'PROMOTE'); +{} +-- long tokens: keys and values longer than the vector width, delimiters at various offsets +SELECT extractKeyValuePairs(concat(repeat('k', 100), ':', repeat('v', 300), ' ', repeat('a', 17), ':', repeat('b', 15), ',', repeat('c', 16), ':', repeat('d', 16))); +{'kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk':'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv','aaaaaaaaaaaaaaaaa':'bbbbbbbbbbbbbbb','cccccccccccccccc':'dddddddddddddddd'} +SELECT extractKeyValuePairsWithEscaping(concat(repeat('k', 100), ':', repeat('v', 300), ' ', repeat('a', 17), ':', repeat('b', 15), ',', repeat('c', 16), ':', repeat('d', 16))); +{'kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk':'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv','aaaaaaaaaaaaaaaaa':'bbbbbbbbbbbbbbb','cccccccccccccccc':'dddddddddddddddd'} +SELECT extractKeyValuePairs(concat('"', repeat('q', 50), '":"', repeat('w', 70), '"', repeat(' ', 40), 'z:1')); +{'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq':'wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww','z':'1'} +SELECT extractKeyValuePairsWithEscaping(concat('"', repeat('q', 50), '":"', repeat('w', 30), '\\t', repeat('w', 30), '"', repeat(' ', 40), 'z:1')); +{'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq':'wwwwwwwwwwwwwwwwwwwwwwwwwwwwww\twwwwwwwwwwwwwwwwwwwwwwwwwwwwww','z':'1'} +SELECT extractKeyValuePairsWithEscaping(concat('k:', repeat('v', 200), '\\n', repeat('v', 200), ' a:b')); +{'k':'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\nvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv','a':'b'} +SELECT extractKeyValuePairs(concat(repeat(':', 17), 'a:b', repeat(' ', 31))); +{'a':'b'} +SELECT extractKeyValuePairs(repeat('x', 100)); +{} +SELECT extractKeyValuePairs(concat(repeat('x', 100), ':', repeat('y', 100))); +{'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx':'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy'} +SELECT length(extractKeyValuePairs(concat('k:', repeat('a', 2000), ' q:"', repeat('b', 2000), '" z:1'))); +3 +SELECT arraySort(mapKeys(extractKeyValuePairs(arrayStringConcat(arrayMap(i -> concat('k', toString(i), ':', toString(i)), range(30)), ' ')))); +['k0','k1','k10','k11','k12','k13','k14','k15','k16','k17','k18','k19','k2','k20','k21','k22','k23','k24','k25','k26','k27','k28','k29','k3','k4','k5','k6','k7','k8','k9'] +-- non-ASCII bytes in keys and values +SELECT extractKeyValuePairs('ключ:значение, é”®:值 "цитата":"кавычки"'); +{'ключ':'значение','é”®':'值','цитата':'кавычки'} +SELECT extractKeyValuePairsWithEscaping('ключ:значение, é”®:值 "цитата":"кавычки"'); +{'ключ':'значение','é”®':'值','цитата':'кавычки'} +SELECT extractKeyValuePairs('a:\xff\xfe b:\x80'); +{'a':'ÿþ','b':'€'} +-- delimiters with bytes outside ASCII +SELECT extractKeyValuePairs('a\x80b\x91c\xa2d', '\x80', '\x91\xa2', '\xb3'); +{'a':'b'} +SELECT extractKeyValuePairs('k1\xf0v1\x80k2\xf0\xb3v 2\xb3\x91k3\xf0v3', '\xf0', '\x80\x91\xa2\xc4\xd5\xe6\x0a\x09', '\xb3'); +{'k1':'v1','k2':'v 2','k3':'v3'} +SELECT extractKeyValuePairsWithEscaping('k1\xf0v1\x80k2\xf0\xb3v 2\xb3\x91k3\xf0v\\n3', '\xf0', '\x80\x91\xa2\xc4\xd5\xe6\x0a\x09', '\xb3'); +{'k1':'v1','k2':'v 2','k3':'v\n3'} +-- FixedString input +SELECT extractKeyValuePairs(toFixedString('a:b c:d', 10)); +{'a':'b','c':'d\0\0\0'} +-- multiple rows, mix of empty and non-empty strings +SELECT extractKeyValuePairs(s), extractKeyValuePairsWithEscaping(s) FROM (SELECT arrayJoin(['', 'a:b', ' ', 'k:"v', 'x:y\\', '::', 'a:b:c d']) AS s); +{} {} +{'a':'b'} {'a':'b'} +{} {} +{} {} +{'x':'y\\'} {'x':'y'} +{} {} +{'a':'b:c'} {'a':'b:c'} diff --git a/tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.sql b/tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.sql new file mode 100644 index 000000000000..875aa2982533 --- /dev/null +++ b/tests/queries/0_stateless/05175_extract_key_value_pairs_escaping_and_long_tokens.sql @@ -0,0 +1,86 @@ +-- Cases ported from the former unit tests of the key-value pair extractor, plus inputs with long tokens +-- (to exercise the vectorized scanning of the extractor) and non-ASCII bytes. + +SET output_format_pretty_single_large_number_tip_threshold = 0; + +-- { echoOn } + +-- standard escape sequences are decoded, the result is checked byte by byte +SELECT hex(x['key1']), hex(x['key2']) FROM (SELECT extractKeyValuePairsWithEscaping('key1:a\\xFF key2:a\\n\\t\\r') AS x); +SELECT extractKeyValuePairsWithEscaping('age:a\\x0A\\n\\0'); +SELECT extractKeyValuePairs('age:a\\x0A\\n\\0'); + +-- quoting character inside a quoted value must be escaped +SELECT extractKeyValuePairsWithEscaping('a:"aaaa\\"bbb" c:d'); +SELECT extractKeyValuePairs('a:"aaaa\\"bbb" c:d'); + +-- non-standard escape sequences keep the backslash +SELECT extractKeyValuePairsWithEscaping('ke\\y:va\\lue k\\x41:v'); + +-- invalid escape sequence at the end of the value: best effort, the value is kept +SELECT extractKeyValuePairsWithEscaping('valid_key:valid_value key:invalid_val\\'); +SELECT extractKeyValuePairs('valid_key:valid_value key:invalid_val\\'); +SELECT extractKeyValuePairsWithEscaping('valid_key:valid_value key:invalid_val\\ third_key:third_value'); +SELECT extractKeyValuePairs('valid_key:valid_value key:invalid_val\\ third_key:third_value'); +SELECT extractKeyValuePairsWithEscaping('valid_key:valid_value key:"invalid val\\ " "third key":"third value"'); +SELECT extractKeyValuePairs('valid_key:valid_value key:"invalid val\\ " "third key":"third value"'); + +-- incomplete hex escape sequence +SELECT extractKeyValuePairsWithEscaping('k\\x4'); +SELECT extractKeyValuePairsWithEscaping('k\\x4:v a:b'); +SELECT extractKeyValuePairsWithEscaping('a:v\\x4'); +SELECT extractKeyValuePairsWithEscaping('a:"v\\x4" b:c'); + +-- leading escape sequences are skipped in keys and are invalid for values +SELECT extractKeyValuePairsWithEscaping('\\:name:val'); +SELECT extractKeyValuePairsWithEscaping('\\\\"name":val'); +SELECT extractKeyValuePairsWithEscaping('a:\\N b:c'); +SELECT extractKeyValuePairsWithEscaping('a:x\\N b:c'); +SELECT extractKeyValuePairsWithEscaping('"\\N":5 a:b'); + +-- escape sequences inside quoted keys +SELECT extractKeyValuePairsWithEscaping('"name\\n\\x4E":1'); +SELECT hex(x['name\nN']) FROM (SELECT extractKeyValuePairsWithEscaping('"name\\n\\x4E":1') AS x); + +-- single quote as quoting character, both variants +SELECT extractKeyValuePairs('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\''); +SELECT extractKeyValuePairsWithEscaping('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\''); + +-- unexpected quoting character strategies with escaping +SELECT extractKeyValuePairsWithEscaping('name"abc:5', ':', ' ,;', '"', 'INVALID'); +SELECT extractKeyValuePairsWithEscaping('name"abc":5', ':', ' ,;', '"', 'INVALID'); +SELECT extractKeyValuePairsWithEscaping('name"abc:5', ':', ' ,;', '"', 'ACCEPT'); +SELECT extractKeyValuePairsWithEscaping('name"abc":5', ':', ' ,;', '"', 'ACCEPT'); +SELECT extractKeyValuePairsWithEscaping('name"abc:5', ':', ' ,;', '"', 'PROMOTE'); +SELECT extractKeyValuePairsWithEscaping('name"abc":5', ':', ' ,;', '"', 'PROMOTE'); +SELECT extractKeyValuePairsWithEscaping('k:v"x y:z', ':', ' ,;', '"', 'INVALID'); +SELECT extractKeyValuePairsWithEscaping('k:v"x y:z', ':', ' ,;', '"', 'ACCEPT'); +SELECT extractKeyValuePairsWithEscaping('k:v"x y:z', ':', ' ,;', '"', 'PROMOTE'); + +-- long tokens: keys and values longer than the vector width, delimiters at various offsets +SELECT extractKeyValuePairs(concat(repeat('k', 100), ':', repeat('v', 300), ' ', repeat('a', 17), ':', repeat('b', 15), ',', repeat('c', 16), ':', repeat('d', 16))); +SELECT extractKeyValuePairsWithEscaping(concat(repeat('k', 100), ':', repeat('v', 300), ' ', repeat('a', 17), ':', repeat('b', 15), ',', repeat('c', 16), ':', repeat('d', 16))); +SELECT extractKeyValuePairs(concat('"', repeat('q', 50), '":"', repeat('w', 70), '"', repeat(' ', 40), 'z:1')); +SELECT extractKeyValuePairsWithEscaping(concat('"', repeat('q', 50), '":"', repeat('w', 30), '\\t', repeat('w', 30), '"', repeat(' ', 40), 'z:1')); +SELECT extractKeyValuePairsWithEscaping(concat('k:', repeat('v', 200), '\\n', repeat('v', 200), ' a:b')); +SELECT extractKeyValuePairs(concat(repeat(':', 17), 'a:b', repeat(' ', 31))); +SELECT extractKeyValuePairs(repeat('x', 100)); +SELECT extractKeyValuePairs(concat(repeat('x', 100), ':', repeat('y', 100))); +SELECT length(extractKeyValuePairs(concat('k:', repeat('a', 2000), ' q:"', repeat('b', 2000), '" z:1'))); +SELECT arraySort(mapKeys(extractKeyValuePairs(arrayStringConcat(arrayMap(i -> concat('k', toString(i), ':', toString(i)), range(30)), ' ')))); + +-- non-ASCII bytes in keys and values +SELECT extractKeyValuePairs('ключ:значение, é”®:值 "цитата":"кавычки"'); +SELECT extractKeyValuePairsWithEscaping('ключ:значение, é”®:值 "цитата":"кавычки"'); +SELECT extractKeyValuePairs('a:\xff\xfe b:\x80'); + +-- delimiters with bytes outside ASCII +SELECT extractKeyValuePairs('a\x80b\x91c\xa2d', '\x80', '\x91\xa2', '\xb3'); +SELECT extractKeyValuePairs('k1\xf0v1\x80k2\xf0\xb3v 2\xb3\x91k3\xf0v3', '\xf0', '\x80\x91\xa2\xc4\xd5\xe6\x0a\x09', '\xb3'); +SELECT extractKeyValuePairsWithEscaping('k1\xf0v1\x80k2\xf0\xb3v 2\xb3\x91k3\xf0v\\n3', '\xf0', '\x80\x91\xa2\xc4\xd5\xe6\x0a\x09', '\xb3'); + +-- FixedString input +SELECT extractKeyValuePairs(toFixedString('a:b c:d', 10)); + +-- multiple rows, mix of empty and non-empty strings +SELECT extractKeyValuePairs(s), extractKeyValuePairsWithEscaping(s) FROM (SELECT arrayJoin(['', 'a:b', ' ', 'k:"v', 'x:y\\', '::', 'a:b:c d']) AS s); From 4029f23ddf4024ec06b1a8a85e1d42c2425192a3 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 10 Sep 2026 19:27:13 +0200 Subject: [PATCH 069/272] Revert "Revert "Support real hard links for plain_rewritable metadata."" --- .../server-config/storing-data.mdx | 42 +- .../DiskObjectStorage/DiskObjectStorage.cpp | 2 +- .../MetadataStorageFromCacheObjectStorage.cpp | 5 + .../MetadataStorageFromCacheObjectStorage.h | 1 + .../MetadataStorages/IMetadataStorage.h | 8 + .../MetadataStorageFactory.cpp | 4 +- .../Metadata/BlobLinkCounts.cpp | 61 ++ .../PlainRewritable/Metadata/BlobLinkCounts.h | 34 + .../PlainRewritable/Metadata/FsMetadata.cpp | 18 +- .../PlainRewritable/Metadata/FsMetadata.h | 4 + .../PlainRewritable/Metadata/FsSnapshot.cpp | 90 +- .../PlainRewritable/Metadata/FsSnapshot.h | 43 +- .../PlainRewritable/Metadata/PrefixPath.cpp | 97 ++ .../PlainRewritable/Metadata/PrefixPath.h | 52 ++ ...torageFromPlainRewritableObjectStorage.cpp | 235 ++++- ...aStorageFromPlainRewritableObjectStorage.h | 37 +- ...PlainRewritableObjectStorageOperations.cpp | 445 ++++++++-- ...omPlainRewritableObjectStorageOperations.h | 85 +- .../PlainRewritable/PlainRewritableLayout.cpp | 5 + .../PlainRewritable/PlainRewritableLayout.h | 2 + .../Transactions/UncommittedState.cpp | 116 +++ .../Transactions/UncommittedState.h | 14 + .../gtest_metadata_plain_rewritable_disk.cpp | 834 +++++++++++++++++- src/Storages/StorageMergeTree.cpp | 46 +- src/Storages/StorageMergeTree.h | 3 + ...4_s3_plain_rewritable_hard_links.reference | 15 + .../05104_s3_plain_rewritable_hard_links.sh | 67 ++ ...able_disk_drop_deduplication_log.reference | 6 + ...05137_table_disk_drop_deduplication_log.sh | 46 + 29 files changed, 2273 insertions(+), 144 deletions(-) create mode 100644 src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.cpp create mode 100644 src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.h create mode 100644 src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.cpp create mode 100644 src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.h create mode 100644 tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.reference create mode 100755 tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.sh create mode 100644 tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.reference create mode 100755 tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.sh diff --git a/docs/concepts/features/configuration/server-config/storing-data.mdx b/docs/concepts/features/configuration/server-config/storing-data.mdx index 5effb9cb58b1..724984320ad0 100644 --- a/docs/concepts/features/configuration/server-config/storing-data.mdx +++ b/docs/concepts/features/configuration/server-config/storing-data.mdx @@ -412,15 +412,16 @@ Configuration: A new disk type `s3_plain_rewritable` was introduced in `24.4`. Similar to the `s3_plain` disk type, it does not require additional storage for metadata files. Instead, metadata is stored in S3. -Unlike the `s3_plain` disk type, `s3_plain_rewritable` allows executing merges +Unlike the `s3_plain` disk type, `s3_plain_rewritable` allows executing merges and supports `INSERT` operations. -[Mutations](/reference/statements/alter/index#mutations) and replication of tables are not supported. +Replication of tables is not supported. +[Mutations](/reference/statements/alter/index#mutations) and `ALTER` operations +are supported only if the disk is configured with `enable_hard_links`, see below. A use case for this disk type is for non-replicated `MergeTree` tables. Although the `s3` disk type is suitable for non-replicated `MergeTree` tables, you may opt for the `s3_plain_rewritable` disk type if you do not require local metadata -for the table and are willing to accept a limited set of operations. This could -be useful, for example, for system tables. +for the table. This could be useful, for example, for system tables. Configuration: @@ -447,6 +448,39 @@ is equal to Starting from `24.5` it is possible to configure any object storage disk (`s3`, `azure`, `local`) using the `plain_rewritable` metadata type. +#### Hard links on plain rewritable disks {#plain-rewritable-hard-links} + +`ALTER` operations and mutations of a `MergeTree` table share the unchanged files +between a part and its mutated successor with hard links, instead of copying +them. The `enable_hard_links` setting (`0` by default) allows a disk with the +`plain_rewritable` metadata to create real hard links, which unlocks these +operations: + +```xml + + s3_plain_rewritable + https://s3.eu-west-1.amazonaws.com/clickhouse-eu-west-1.clickhouse.com/data/ + 1 + 1 + +``` + +A hard link makes the metadata of a directory list its files explicitly, together +with the keys of the objects storing them, so that a file can refer to an object +stored under the prefix of another directory. Consider the following before +enabling it: + +- Servers older than `26.9` cannot read a directory in this form. A disk that has + such directories is not readable after a downgrade, and cannot be shared with + older servers. Directories that never had hard links keep the previous layout, + so a disk stays readable by older servers until the first operation that + creates a hard link - a mutation, an `ALTER`, a `FREEZE` or a backup. Turning + the setting off again does not convert the existing directories back. +- An object whose last link is removed is deleted in two steps: the metadata of + the directory is rewritten first, and the object is deleted after that. If the + server is killed in between, the object is left in the bucket and is not + referenced by anything, so it has to be deleted manually. + ### Using Azure Blob Storage {#azure-blob-storage} `MergeTree` family table engines can store data to [Azure Blob Storage](https://azure.microsoft.com/en-us/services/storage/blobs/) diff --git a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp index 4ddfe51d30f2..7de5e38e5999 100644 --- a/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/DiskObjectStorage.cpp @@ -784,7 +784,7 @@ bool DiskObjectStorage::isSharedCompatible() const bool DiskObjectStorage::supportsHardLinks() const { - return !metadata_storage->isWriteOnce() && !metadata_storage->isPlain(); + return metadata_storage->supportsHardLinks(); } String DiskObjectStorage::getReadResourceNameNoLock() const diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp index d5c308dc8bea..5a2c42396b1e 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.cpp @@ -199,6 +199,11 @@ bool MetadataStorageFromCacheObjectStorage::isWriteOnce() const return underlying->isWriteOnce(); } +bool MetadataStorageFromCacheObjectStorage::supportsHardLinks() const +{ + return underlying->supportsHardLinks(); +} + bool MetadataStorageFromCacheObjectStorage::supportWritingWithAppend() const { return underlying->supportWritingWithAppend(); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h index 3c0944561a01..71665af8a1a8 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/Cache/MetadataStorageFromCacheObjectStorage.h @@ -69,6 +69,7 @@ public: bool isTransactional() const override; bool isPlain() const override; bool isWriteOnce() const override; + bool supportsHardLinks() const override; bool supportWritingWithAppend() const override; BlobsToRemove getBlobsToRemove(const ClusterConfigurationPtr & cluster, int64_t max_count) override; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index 98ea21201c70..5dd34d15b9d7 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h @@ -323,6 +323,14 @@ public: return false; } + /// Whether a hard link created by `createHardLink` really shares the blob between the two files, + /// instead of copying it. The write-once (`plain`) metadata cannot represent hard links at all, + /// and `plain_rewritable` does it only when the disk is configured for it. + virtual bool supportsHardLinks() const + { + return !isWriteOnce() && !isPlain(); + } + using BlobsToRemove = std::unordered_map; virtual BlobsToRemove getBlobsToRemove(const ClusterConfigurationPtr & /*cluster*/, int64_t /*max_count*/) { return {}; } virtual int64_t recordAsRemoved(const StoredObjects & /*blobs*/) { return 0; } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp index 1b16157562b1..3073015ff2a5 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp @@ -201,8 +201,10 @@ static void registerPlainRewritableMetadataStorage(MetadataStorageFactory & fact const auto local_object_storage = object_storages->takePointingTo(cluster->getLocalLocation()); std::string key_compatibility_prefix = getObjectKeyCompatiblePrefix(local_object_storage, config, config_prefix); + /// Hard links make the metadata of a directory unreadable by older servers, so they are opt-in. + bool enable_hard_links = config.getBool(config_prefix + ".enable_hard_links", false); - return std::make_shared(local_object_storage, key_compatibility_prefix); + return std::make_shared(local_object_storage, key_compatibility_prefix, enable_hard_links); }); } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.cpp new file mode 100644 index 000000000000..8f93322837fe --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.cpp @@ -0,0 +1,61 @@ +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int LOGICAL_ERROR; +} + +uint32_t BlobLinkCounts::get(const std::string & blob_key) const +{ + std::lock_guard lock(mutex); + const auto it = counts.find(blob_key); + return it == counts.end() ? 1 : it->second; +} + +void BlobLinkCounts::apply(const std::unordered_map & deltas) +{ + std::lock_guard lock(mutex); + for (const auto & [blob_key, delta] : deltas) + { + if (delta == 0) + continue; + + const auto it = counts.find(blob_key); + const int64_t previous = it == counts.end() ? 1 : it->second; + const int64_t updated = previous + delta; + + /// Zero means that the last link was removed: the blob is not tracked anymore. + if (updated < 0) + throw Exception(ErrorCodes::LOGICAL_ERROR, "Negative number of links ({}) to the blob '{}'", updated, blob_key); + + if (updated <= 1) + { + if (it != counts.end()) + counts.erase(it); + } + else if (it == counts.end()) + { + counts.emplace(blob_key, static_cast(updated)); + } + else + { + it->second = static_cast(updated); + } + } +} + +void BlobLinkCounts::replace(const std::unordered_map & new_counts) +{ + std::lock_guard lock(mutex); + counts.clear(); + for (const auto & [blob_key, count] : new_counts) + if (count > 1) + counts.emplace(blob_key, count); +} + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.h new file mode 100644 index 000000000000..10eb07e9d66e --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/BlobLinkCounts.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace DB +{ + +/// The number of files referencing each blob of a plain-rewritable disk, for the blobs shared by hard links. +/// +/// Only the blobs with more than one link are stored. Every blob known to the metadata is referenced by at least one file, +/// so a blob absent from the map has exactly one link. This keeps the structure small: it is empty until hard links are created, +/// and MergeTree shares blobs only between a part and its mutated successor, until the old part is removed. +class BlobLinkCounts +{ +public: + uint32_t get(const std::string & blob_key) const; + + /// Adds the deltas accumulated by a transaction. + void apply(const std::unordered_map & deltas); + + /// Replaces the contents with the counts calculated from the loaded layout. Counts of 1 are dropped. + void replace(const std::unordered_map & new_counts); + +private: + mutable std::mutex mutex; + std::unordered_map counts TSA_GUARDED_BY(mutex); +}; + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.cpp index 5d86fcecccac..7a6d2d450ed3 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -7,7 +8,8 @@ namespace DB { FsMetadata::FsMetadata(CurrentMetrics::Metric metric_directories_name, CurrentMetrics::Metric metric_files_name) - : latest_snapshot(std::make_shared()) + : blob_link_counts(std::make_shared()) + , latest_snapshot(std::make_shared(blob_link_counts)) , remote_layout_directories_count(metric_directories_name, 0) , remote_layout_files_count(metric_files_name, 0) { @@ -16,8 +18,12 @@ FsMetadata::FsMetadata(CurrentMetrics::Metric metric_directories_name, CurrentMe void FsMetadata::applySnapshot(std::shared_ptr snapshot) { const auto [directories_delta, files_delta] = snapshot->getRemoteLayoutDeltas(); + const auto blob_link_deltas = snapshot->getBlobLinkDeltas(); UniqueLock lock(mutex); + blob_link_counts->apply(blob_link_deltas); + /// The deltas are now part of the committed counts; drop them so reads through the committed snapshot do not re-apply them. + snapshot->resetDeltas(); latest_snapshot = std::move(snapshot); remote_layout_directories_count.add(directories_delta); remote_layout_files_count.add(files_delta); @@ -25,13 +31,19 @@ void FsMetadata::applySnapshot(std::shared_ptr snapshot) void FsMetadata::applyLayout(std::unordered_map remote_layout) { - auto new_tree = std::make_shared(); + std::unordered_map new_blob_link_counts; + for (const auto & [path, info] : remote_layout) + for (const auto & [file_name, file_info] : info.files) + ++new_blob_link_counts[getBlobKey(info, file_name, file_info)]; + + auto new_tree = std::make_shared(blob_link_counts); for (auto & [path, info] : remote_layout) new_tree->recordDirectoryPath(path, std::move(info)); const auto [directories_count, files_count] = new_tree->getRemoteLayoutDeltas(); UniqueLock lock(mutex); + blob_link_counts->replace(new_blob_link_counts); latest_snapshot = std::move(new_tree); remote_layout_directories_count.changeTo(directories_count); remote_layout_files_count.changeTo(files_count); @@ -40,7 +52,7 @@ void FsMetadata::applyLayout(std::unordered_map FsMetadata::takeReadWriteSnapshot() const { UniqueLock lock(mutex); - return std::make_shared(latest_snapshot->getRoot()); + return std::make_shared(latest_snapshot->getRoot(), blob_link_counts); } std::shared_ptr FsMetadata::takeReadOnlySnapshot() const diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.h index 039f70c5f8f0..e8e45162b352 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsMetadata.h @@ -10,6 +10,9 @@ namespace DB { +class BlobLinkCounts; + +/// The committed state of the plain-rewritable metadata: the tree of directories and files, and the numbers of links to the blobs. class FsMetadata { public: @@ -23,6 +26,7 @@ public: private: mutable std::mutex mutex; + const std::shared_ptr blob_link_counts; std::shared_ptr latest_snapshot TSA_GUARDED_BY(mutex); mutable CurrentMetrics::Increment remote_layout_directories_count TSA_GUARDED_BY(mutex); mutable CurrentMetrics::Increment remote_layout_files_count TSA_GUARDED_BY(mutex); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.cpp index 730d358fc0e7..25e97b01a49a 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -20,6 +21,7 @@ namespace ErrorCodes extern const int DIRECTORY_DOESNT_EXIST; extern const int FILE_ALREADY_EXISTS; extern const int FILE_DOESNT_EXIST; + extern const int LOGICAL_ERROR; } namespace @@ -165,14 +167,30 @@ FsNodePtr unlinkTree(const FsNodePtr & root, const NormalizedPath & path) } -FsSnapshot::FsSnapshot() - : root(std::make_shared()) +std::string getDefaultBlobKey(const std::string & directory_remote_path, const std::string & file_name) { + return directory_remote_path + "/" + file_name; } -FsSnapshot::FsSnapshot(std::shared_ptr root_) - : root(std::move(root_)) +std::string getBlobKey(const DirectoryRemoteInfo & directory, const std::string & file_name, const FileRemoteInfo & file) { + if (!file.blob_key.empty()) + return file.blob_key; + return getDefaultBlobKey(directory.remote_path, file_name); +} + +FsSnapshot::FsSnapshot(std::shared_ptr blob_link_counts_) + : root(std::make_shared()) + , blob_link_counts(std::move(blob_link_counts_)) +{ + chassert(blob_link_counts); +} + +FsSnapshot::FsSnapshot(std::shared_ptr root_, std::shared_ptr blob_link_counts_) + : root(std::move(root_)) + , blob_link_counts(std::move(blob_link_counts_)) +{ + chassert(blob_link_counts); } void FsSnapshot::recordDirectoryPath(const std::string & path, DirectoryRemoteInfo info) @@ -240,6 +258,26 @@ void FsSnapshot::removeDirectory(const std::string & path) }); } +void FsSnapshot::markDirectoryExplicit(const std::string & path) +{ + UniqueLock lock(mutex); + const auto normalized_path = normalizePath(path); + const auto node = walk(root, normalized_path); + + if (!node) + throw Exception(ErrorCodes::DIRECTORY_DOESNT_EXIST, "Directory '{}' does not exist", normalized_path.string()); + + if (isVirtual(node)) + throw Exception(ErrorCodes::LOGICAL_ERROR, "Directory '{}' is virtual, it cannot have an explicit file list", normalized_path.string()); + + if (node->info->has_explicit_file_list) + return; + + auto new_directory_info = node->info.value(); + new_directory_info.has_explicit_file_list = true; + root = updateInfo(root, normalized_path, new_directory_info); +} + void FsSnapshot::recordFile(const std::string & path, FileRemoteInfo info) { UniqueLock lock(mutex); @@ -258,6 +296,10 @@ void FsSnapshot::recordFile(const std::string & path, FileRemoteInfo info) if (node->info->files.contains(normalized_path.filename())) throw Exception(ErrorCodes::FILE_ALREADY_EXISTS, "File '{}' already exists", normalized_path.string()); + /// The default location is represented by an empty key. + if (info.blob_key == getDefaultBlobKey(node->info->remote_path, normalized_path.filename())) + info.blob_key.clear(); + auto new_directory_info = node->info.value(); new_directory_info.files.emplace(normalized_path.filename(), std::move(info)); root = updateInfo(root, normalized_path.parent_path(), new_directory_info); @@ -285,6 +327,31 @@ void FsSnapshot::removeFile(const std::string & path) remote_layout_files_delta -= 1; } +uint32_t FsSnapshot::getBlobLinkCount(const std::string & blob_key) const +{ + UniqueLock lock(mutex); + int64_t count = blob_link_counts->get(blob_key); + if (const auto it = blob_link_deltas.find(blob_key); it != blob_link_deltas.end()) + count += it->second; + + if (count < 1) + throw Exception(ErrorCodes::LOGICAL_ERROR, "Blob '{}' is not referenced by any file", blob_key); + + return static_cast(count); +} + +void FsSnapshot::addBlobLink(const std::string & blob_key) +{ + UniqueLock lock(mutex); + ++blob_link_deltas[blob_key]; +} + +void FsSnapshot::removeBlobLink(const std::string & blob_key) +{ + UniqueLock lock(mutex); + --blob_link_deltas[blob_key]; +} + std::vector FsSnapshot::listDirectory(const std::string & path) const { UniqueLock lock(mutex); @@ -378,6 +445,7 @@ void FsSnapshot::setRoot(std::shared_ptr new_root) { UniqueLock lock(mutex); root = std::move(new_root); + blob_link_deltas.clear(); remote_layout_directories_delta = 0; remote_layout_files_delta = 0; } @@ -388,4 +456,18 @@ std::pair FsSnapshot::getRemoteLayoutDeltas() const return {remote_layout_directories_delta, remote_layout_files_delta}; } +std::unordered_map FsSnapshot::getBlobLinkDeltas() const +{ + UniqueLock lock(mutex); + return blob_link_deltas; +} + +void FsSnapshot::resetDeltas() +{ + UniqueLock lock(mutex); + blob_link_deltas.clear(); + remote_layout_directories_delta = 0; + remote_layout_files_delta = 0; +} + } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.h index 0599e6d17400..6a959f05d7d4 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/FsSnapshot.h @@ -18,8 +18,12 @@ namespace DB struct FileRemoteInfo { - size_t bytes_size; - time_t last_modified; + size_t bytes_size = 0; + time_t last_modified = 0; + /// Key of the blob relative to the common key prefix of the object storage, e.g. `aaealinyzgdzycgcnpgaapdssrjirnnr/hello.json`. + /// Empty when the blob is stored at the default location, `/`. + /// Files that were hard-linked from another directory point to the blob of the original file, which lives elsewhere. + std::string blob_key; }; struct DirectoryRemoteInfo @@ -28,8 +32,19 @@ struct DirectoryRemoteInfo std::string etag; time_t last_modified = 0; std::unordered_map files; + /// The `prefix.path` object of this directory lists the files explicitly (see `PrefixPath.h`). + /// In this form the set of files is defined by the list, not by the blobs stored under the directory prefix, + /// so the blobs may be shared with other directories (hard links) and may have arbitrary names. + bool has_explicit_file_list = false; }; +/// Key of the blob of a file, relative to the common key prefix of the object storage. +std::string getBlobKey(const DirectoryRemoteInfo & directory, const std::string & file_name, const FileRemoteInfo & file); +/// Blob key of a file stored at the default location. +std::string getDefaultBlobKey(const std::string & directory_remote_path, const std::string & file_name); + +class BlobLinkCounts; + struct FsNode : public std::enable_shared_from_this { std::optional info = {}; @@ -37,23 +52,36 @@ struct FsNode : public std::enable_shared_from_this }; /// Mutable snapshot of the virtual file system tree. +/// +/// The tree itself is immutable and shared between snapshots (copy-on-write), so a snapshot can be discarded at any point. +/// The numbers of links to the blobs are shared with the committed metadata (`BlobLinkCounts`), and the snapshot records +/// its changes to them as deltas, which are applied to the committed state together with the tree in `FsMetadata::applySnapshot`. class FsSnapshot { public: - explicit FsSnapshot(); - explicit FsSnapshot(std::shared_ptr root_); + explicit FsSnapshot(std::shared_ptr blob_link_counts_); + FsSnapshot(std::shared_ptr root_, std::shared_ptr blob_link_counts_); /// Directory Write Methods void recordDirectoryPath(const std::string & path, DirectoryRemoteInfo info); void moveDirectory(const std::string & from, const std::string & to); void removeDirectory(const std::string & path); + /// Switches the directory to the explicit file list form. This is a one-way transition. + void markDirectoryExplicit(const std::string & path); /// File Write Methods void recordFile(const std::string & path, FileRemoteInfo info); void removeFile(const std::string & path); + /// Blob Link Methods. The blob must be referenced by at least one file. + + /// The number of files sharing the blob. + uint32_t getBlobLinkCount(const std::string & blob_key) const; + void addBlobLink(const std::string & blob_key); + void removeBlobLink(const std::string & blob_key); + /// Directory Read Methods std::vector listDirectory(const std::string & path) const; @@ -69,12 +97,19 @@ public: /// Snapshot Methods std::shared_ptr getRoot() const; + /// Also forgets all the deltas accumulated by the snapshot. void setRoot(std::shared_ptr new_root); std::pair getRemoteLayoutDeltas() const; + std::unordered_map getBlobLinkDeltas() const; + /// Forgets the accumulated deltas without changing the tree. Called after the deltas have been folded + /// into the committed state, so a snapshot promoted to the committed one does not double-count them. + void resetDeltas(); private: mutable std::mutex mutex; std::shared_ptr root TSA_GUARDED_BY(mutex); + const std::shared_ptr blob_link_counts; + std::unordered_map blob_link_deltas TSA_GUARDED_BY(mutex); mutable int64_t remote_layout_directories_delta TSA_GUARDED_BY(mutex) = 0; mutable int64_t remote_layout_files_delta TSA_GUARDED_BY(mutex) = 0; }; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.cpp new file mode 100644 index 000000000000..264fe01c461b --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.cpp @@ -0,0 +1,97 @@ +#include + +#include +#include +#include +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int INCORRECT_DATA; +} + +namespace +{ + +constexpr const char * FILES_HEADER = "files: "; + +} + +std::string serializePrefixPath(const std::string & logical_path, const DirectoryRemoteInfo & directory) +{ + if (!directory.has_explicit_file_list) + return logical_path; + + /// Sorted to make the contents deterministic. + std::vector> files; + files.reserve(directory.files.size()); + for (const auto & [name, info] : directory.files) + files.emplace_back(name, &info); + std::ranges::sort(files, {}, &std::pair::first); + + WriteBufferFromOwnString out; + writeString(logical_path, out); + writeChar('\n', out); + writeString(FILES_HEADER, out); + writeIntText(files.size(), out); + writeChar('\n', out); + + for (const auto & [name, info] : files) + { + writeEscapedString(name, out); + writeChar('\t', out); + writeEscapedString(getBlobKey(directory, std::string(name), *info), out); + writeChar('\t', out); + writeIntText(info->bytes_size, out); + writeChar('\n', out); + } + + return out.str(); +} + +PrefixPathContents parsePrefixPath(std::string_view contents) +{ + PrefixPathContents result; + ReadBufferFromString in(contents); + + readStringUntilNewlineInto(result.logical_path, in); + if (in.eof()) + return result; + + assertChar('\n', in); + result.has_explicit_file_list = true; + + assertString(FILES_HEADER, in); + size_t files_count = 0; + readIntText(files_count, in); + assertChar('\n', in); + + result.files.reserve(files_count); + for (size_t i = 0; i < files_count; ++i) + { + PrefixPathContents::File file; + readEscapedString(file.name, in); + assertChar('\t', in); + readEscapedString(file.blob_key, in); + assertChar('\t', in); + readIntText(file.bytes_size, in); + assertChar('\n', in); + + if (file.name.empty() || file.blob_key.empty()) + throw Exception(ErrorCodes::INCORRECT_DATA, "Empty file name or blob key in the file list of the directory '{}'", result.logical_path); + + result.files.push_back(std::move(file)); + } + + if (!in.eof()) + throw Exception(ErrorCodes::INCORRECT_DATA, "Unexpected data after the file list of the directory '{}'", result.logical_path); + + return result; +} + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.h new file mode 100644 index 000000000000..8c479e927fd9 --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Metadata/PrefixPath.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +#include +#include +#include + +namespace DB +{ + +/** The contents of a `prefix.path` object, which maps the random remote prefix of a directory to its logical path. + * + * The object has two forms. + * + * In the implicit form it contains just the logical path of the directory: + * + * /hello/world/ + * + * The files of the directory are the blobs stored under the remote prefix of the directory, under their own names. + * + * In the explicit form the logical path is followed by the list of files with the keys of their blobs + * (relative to the common key prefix of the disk) and their sizes: + * + * /hello/world/ + * files: 2 + * hello.json gfkoqxvyhaasroiodbeurnftnwieiihy/hello.json 1234 + * upyachka.bin aaealinyzgdzycgcnpgaapdssrjirnnr/upyachka.bin 567 + * + * The file names and the blob keys are escaped as in the `TSV` format. The blobs may live under a different prefix + * (a hard link to a file of another directory), and the blobs stored under the prefix but absent from the list + * do not belong to the directory. The explicit form is used only for directories that have (or had) hard links, + * so a disk without hard links keeps the implicit form everywhere and stays readable by older versions. + */ +struct PrefixPathContents +{ + struct File + { + std::string name; + std::string blob_key; + size_t bytes_size = 0; + }; + + std::string logical_path; + bool has_explicit_file_list = false; + std::vector files; +}; + +std::string serializePrefixPath(const std::string & logical_path, const DirectoryRemoteInfo & directory); +PrefixPathContents parsePrefixPath(std::string_view contents); + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp index 79791da9d183..90e16dbe4633 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -60,6 +61,19 @@ fs::path normalizeDirectoryPath(const fs::path & path) return path / ""; } +std::optional getBlobKeyIfExists(const FsSnapshot & snapshot, const NormalizedPath & path) +{ + const auto directory = snapshot.getDirectoryRemoteInfo(path.parent_path()); + if (!directory) + return std::nullopt; + + const auto it = directory->files.find(path.filename()); + if (it == directory->files.end()) + return std::nullopt; + + return getBlobKey(*directory, path.filename(), it->second); +} + } void MetadataStorageFromPlainRewritableObjectStorage::load(bool is_initial_load, bool do_not_load_unchanged_directories) @@ -125,16 +139,6 @@ void MetadataStorageFromPlainRewritableObjectStorage::load(bool is_initial_load, ThreadPoolCallbackRunnerLocal runner(pool, ThreadName::PLAIN_REWRITABLE_META_LOAD); try { - /// Root folder is a special case. Files are stored as /__root/{file-name}. - for (auto iterator = object_storage->iterate(layout->constructRootFilesDirectoryKey(), 0, /*with_tags=*/ false, std::nullopt); iterator->isValid(); iterator->next()) - { - auto remote_file = iterator->current(); - remote_layout[""].files.emplace(remote_file->getFileName(), FileRemoteInfo{ - .bytes_size = remote_file->metadata->size_bytes, - .last_modified = remote_file->metadata->last_modified.epochTime(), - }); - } - for (auto iterator = object_storage->iterate(layout->constructMetadataDirectoryKey(), 0, /*with_tags=*/ false, std::nullopt); iterator->isValid(); iterator->next()) { const auto file = iterator->current(); @@ -155,6 +159,7 @@ void MetadataStorageFromPlainRewritableObjectStorage::load(bool is_initial_load, StoredObject object{object_path}; String local_path; + bool has_explicit_file_list = false; /// Assuming that local and the object storage clocks are synchronized. Poco::Timestamp last_modified = metadata->last_modified; std::unordered_map files; @@ -166,7 +171,39 @@ void MetadataStorageFromPlainRewritableObjectStorage::load(bool is_initial_load, else { auto read_buf = object_storage->readObject(object, settings); - readStringUntilEOF(local_path, *read_buf); + String contents; + readStringUntilEOF(contents, *read_buf); + + auto prefix_path = parsePrefixPath(contents); + local_path = std::move(prefix_path.logical_path); + has_explicit_file_list = prefix_path.has_explicit_file_list; + + /// In the explicit form, the list of files comes from the metadata object and the blobs are not listed. + for (auto & listed_file : prefix_path.files) + { + if (listed_file.blob_key == getDefaultBlobKey(remote_path.value(), listed_file.name)) + listed_file.blob_key.clear(); + + files.emplace(std::move(listed_file.name), FileRemoteInfo{ + .bytes_size = listed_file.bytes_size, + .last_modified = last_modified.epochTime(), + .blob_key = std::move(listed_file.blob_key), + }); + } + } + + /// The root directory has a metadata object only in the explicit form, and it is stored under the reserved remote path. + if (remote_path.value() == PlainRewritableLayout::ROOT_DIRECTORY_TOKEN) + local_path.clear(); + else if (normalizePath(local_path).empty()) + { + /// Only the reserved metadata object maps to the logical root, so an empty logical path here means that + /// the object does not describe a directory: it is either not written yet (`LocalObjectStorage` writes + /// to the final key directly, so an interrupted write can leave the object empty and visible), + /// or it is a leftover of a directory that has been removed. Loading it as the root would hide the real + /// root and send lookups under it to the prefix of this directory. + LOG_WARNING(log, "The object with the key '{}' does not contain the logical path of a directory, ignoring it", object_path); + return; } if (do_not_load_unchanged_directories) @@ -180,24 +217,28 @@ void MetadataStorageFromPlainRewritableObjectStorage::load(bool is_initial_load, } } - /// Load the list of files inside the directory. - for (auto dir_iterator = object_storage->iterate(layout->constructFilesDirectoryKey(remote_path.value()), 0, /*with_tags=*/ false, std::nullopt); dir_iterator->isValid(); dir_iterator->next()) + /// In the implicit form, the files of the directory are the blobs stored under its prefix. + if (!has_explicit_file_list) { - const auto remote_file = dir_iterator->current(); - const auto unpacked_remote_file_path = layout->parseFileObjectKey(remote_file->getPath()); - if (!unpacked_remote_file_path.has_value()) + for (auto dir_iterator = object_storage->iterate(layout->constructFilesDirectoryKey(remote_path.value()), 0, /*with_tags=*/ false, std::nullopt); dir_iterator->isValid(); dir_iterator->next()) { - LOG_WARNING(log, "Legacy layout is in use, ignoring '{}'", remote_file->getPath()); - continue; + const auto remote_file = dir_iterator->current(); + const auto unpacked_remote_file_path = layout->parseFileObjectKey(remote_file->getPath()); + if (!unpacked_remote_file_path.has_value()) + { + LOG_WARNING(log, "Legacy layout is in use, ignoring '{}'", remote_file->getPath()); + continue; + } + + const auto & [directory_remote_path, filename] = unpacked_remote_file_path.value(); + chassert(directory_remote_path == remote_path); + + files.emplace(filename, FileRemoteInfo{ + .bytes_size = remote_file->metadata->size_bytes, + .last_modified = remote_file->metadata->last_modified.epochTime(), + .blob_key = {}, + }); } - - const auto & [directory_remote_path, filename] = unpacked_remote_file_path.value(); - chassert(directory_remote_path == remote_path); - - files.emplace(filename, FileRemoteInfo{ - .bytes_size = remote_file->metadata->size_bytes, - .last_modified = remote_file->metadata->last_modified.epochTime(), - }); } #if USE_AZURE_BLOB_STORAGE @@ -235,7 +276,7 @@ void MetadataStorageFromPlainRewritableObjectStorage::load(bool is_initial_load, } std::lock_guard guard(remote_layout_mutex); - remote_layout[local_path] = DirectoryRemoteInfo{remote_path.value(), metadata->etag, last_modified.epochTime(), std::move(files)}; + remote_layout[local_path] = DirectoryRemoteInfo{remote_path.value(), metadata->etag, last_modified.epochTime(), std::move(files), has_explicit_file_list}; }); } } @@ -247,16 +288,31 @@ void MetadataStorageFromPlainRewritableObjectStorage::load(bool is_initial_load, runner.waitForAllToFinishAndRethrowFirstError(); + /// Root folder is a special case. Files are stored as /__root/{file-name}, unless the root has switched to the explicit file list. + if (!remote_layout[""].has_explicit_file_list) + { + for (auto iterator = object_storage->iterate(layout->constructRootFilesDirectoryKey(), 0, /*with_tags=*/ false, std::nullopt); iterator->isValid(); iterator->next()) + { + auto remote_file = iterator->current(); + remote_layout[""].files.emplace(remote_file->getFileName(), FileRemoteInfo{ + .bytes_size = remote_file->metadata->size_bytes, + .last_modified = remote_file->metadata->last_modified.epochTime(), + .blob_key = {}, + }); + } + } + LOG_DEBUG(log, "Loaded metadata for {} directories", remote_layout.size()); fs.applyLayout(std::move(remote_layout)); previous_refresh.restart(); } -MetadataStorageFromPlainRewritableObjectStorage::MetadataStorageFromPlainRewritableObjectStorage(ObjectStoragePtr object_storage_, String storage_path_prefix_) +MetadataStorageFromPlainRewritableObjectStorage::MetadataStorageFromPlainRewritableObjectStorage(ObjectStoragePtr object_storage_, String storage_path_prefix_, bool hard_links_enabled_) : object_storage(std::move(object_storage_)) , metrics(createPlainRewritableMetrics(object_storage->getType())) , storage_path_prefix(std::move(storage_path_prefix_)) , storage_path_full(fs::path(object_storage->getRootPrefix()) / storage_path_prefix) + , hard_links_enabled(hard_links_enabled_) , fs(metrics->directory_map_size, metrics->file_count) , layout(std::make_shared(object_storage->getCommonKeyPrefix())) { @@ -347,17 +403,34 @@ std::optional MetadataStorageFromPlainRewritableObjectStorage::ge { const auto tree = fs.takeReadOnlySnapshot(); - const auto file_remote_info = tree->getFileRemoteInfo(path); - if (!file_remote_info) - return std::nullopt; - const auto normalized_path = normalizePath(path); const auto directory_remote_info = tree->getDirectoryRemoteInfo(normalized_path.parent_path()); if (!directory_remote_info) return std::nullopt; - auto object_key = layout->constructFileObjectKey(directory_remote_info->remote_path, normalized_path.filename()); - return StoredObjects{StoredObject(object_key, path, file_remote_info->bytes_size)}; + const auto file_it = directory_remote_info->files.find(normalized_path.filename()); + if (file_it == directory_remote_info->files.end()) + return std::nullopt; + + auto object_key = layout->constructBlobObjectKey(getBlobKey(*directory_remote_info, normalized_path.filename(), file_it->second)); + return StoredObjects{StoredObject(object_key, path, file_it->second.bytes_size)}; +} + +uint32_t MetadataStorageFromPlainRewritableObjectStorage::getHardlinkCount(const std::string & path) const +{ + const auto tree = fs.takeReadOnlySnapshot(); + + const auto normalized_path = normalizePath(path); + const auto directory_remote_info = tree->getDirectoryRemoteInfo(normalized_path.parent_path()); + if (!directory_remote_info) + throw Exception(ErrorCodes::FILE_DOESNT_EXIST, "File {} does not exist", path); + + const auto file_it = directory_remote_info->files.find(normalized_path.filename()); + if (file_it == directory_remote_info->files.end()) + throw Exception(ErrorCodes::FILE_DOESNT_EXIST, "File {} does not exist", path); + + /// As for the other metadata storages, this is the number of links besides the file itself. + return tree->getBlobLinkCount(getBlobKey(*directory_remote_info, normalized_path.filename(), file_it->second)) - 1; } Poco::Timestamp MetadataStorageFromPlainRewritableObjectStorage::getLastModified(const std::string & path) const @@ -391,7 +464,7 @@ std::optional MetadataStorageFromPlainRewritableObjectStorage:: MetadataStorageFromPlainRewritableObjectStorageTransaction::MetadataStorageFromPlainRewritableObjectStorageTransaction(MetadataStorageFromPlainRewritableObjectStorage & metadata_storage_) : metadata_storage(metadata_storage_) - , commit_snapshot(std::make_shared()) + , commit_snapshot(metadata_storage.fs.takeReadWriteSnapshot()) , uncommitted_state(metadata_storage.fs.takeReadWriteSnapshot()) { } @@ -427,13 +500,25 @@ TransactionCommitOutcomeVariant MetadataStorageFromPlainRewritableObjectStorageT void MetadataStorageFromPlainRewritableObjectStorageTransaction::createMetadataFile(const std::string & path, const StoredObjects & objects) { + /// The blob has been written to the key chosen by `generateObjectKeyForPath`; if the key was not generated + /// by this transaction, the blob is expected at the default location. + std::string blob_key; + if (const auto it = generated_blob_keys.find(normalizePath(path).string()); it != generated_blob_keys.end()) + blob_key = it->second; + + /// The following operations of this transaction have to see the file: a hard link to it makes its blob shared, + /// and then rewriting it has to pick a new blob instead of clobbering the shared one. + uncommitted_state.recordCreatedFile(path, blob_key); + operations.addOperation(std::make_unique( path, objects.front(), + std::move(blob_key), commit_snapshot, metadata_storage.object_storage, metadata_storage.layout, - metadata_storage.metrics)); + metadata_storage.metrics, + removed_objects)); } void MetadataStorageFromPlainRewritableObjectStorageTransaction::createDirectory(const std::string & path) @@ -491,7 +576,13 @@ void MetadataStorageFromPlainRewritableObjectStorageTransaction::moveDirectory(c void MetadataStorageFromPlainRewritableObjectStorageTransaction::unlinkFile(const std::string & path, bool if_exists, bool /*should_remove_objects*/) { - uncommitted_state.useDirectory(normalizePath(path).parent_path()); + const auto normalized_path = normalizePath(path); + uncommitted_state.useDirectory(normalized_path.parent_path()); + + /// Removing a file whose blob is shared switches the directory to the explicit file list. + if (const auto blob_key = getBlobKeyIfExists(uncommitted_state.getSnapshot(), normalized_path); + blob_key && uncommitted_state.getSnapshot().getBlobLinkCount(*blob_key) > 1) + uncommitted_state.markDirectoryExplicit(normalized_path.parent_path()); operations.addOperation(std::make_unique( path, @@ -532,10 +623,28 @@ void MetadataStorageFromPlainRewritableObjectStorageTransaction::removeRecursive void MetadataStorageFromPlainRewritableObjectStorageTransaction::createHardLink(const std::string & path_from, const std::string & path_to) { - uncommitted_state.useDirectory(normalizePath(path_from).parent_path()); - uncommitted_state.useDirectory(normalizePath(path_to).parent_path()); + const auto normalized_path_from = normalizePath(path_from); + const auto normalized_path_to = normalizePath(path_to); + uncommitted_state.useDirectory(normalized_path_from.parent_path()); + uncommitted_state.useDirectory(normalized_path_to.parent_path()); - operations.addOperation(std::make_unique( + /// A real hard link would make the metadata of the target directory unreadable by older servers, so it is opt-in. + if (!metadata_storage.hard_links_enabled) + { + operations.addOperation(std::make_unique( + path_from, + path_to, + commit_snapshot, + metadata_storage.object_storage, + metadata_storage.layout, + metadata_storage.metrics)); + return; + } + + /// The target directory switches to the explicit file list and the blob becomes shared. + uncommitted_state.recordHardLink(path_from, path_to); + + operations.addOperation(std::make_unique( path_from, path_to, commit_snapshot, @@ -544,10 +653,33 @@ void MetadataStorageFromPlainRewritableObjectStorageTransaction::createHardLink( metadata_storage.metrics)); } +void MetadataStorageFromPlainRewritableObjectStorageTransaction::planFileMove(const NormalizedPath & path_from, const NormalizedPath & path_to) +{ + uncommitted_state.useDirectory(path_from.parent_path()); + uncommitted_state.useDirectory(path_to.parent_path()); + + const auto & snapshot = uncommitted_state.getSnapshot(); + const auto directory_from = snapshot.getDirectoryRemoteInfo(path_from.parent_path()); + const auto directory_to = snapshot.getDirectoryRemoteInfo(path_to.parent_path()); + const auto blob_key_from = getBlobKeyIfExists(snapshot, path_from); + if (!directory_from || !directory_to || !blob_key_from) + return; + + const bool metadata_only = isMetadataOnlyMove(snapshot, *directory_from, *directory_to, *blob_key_from, getBlobKeyIfExists(snapshot, path_to)); + if (metadata_only) + { + uncommitted_state.markDirectoryExplicit(path_from.parent_path()); + uncommitted_state.markDirectoryExplicit(path_to.parent_path()); + } + + /// The moved file has to be visible at its new path: a hard link to it makes its blob shared, and then rewriting it + /// has to pick a new blob instead of clobbering the shared one. + uncommitted_state.recordMovedFile(path_from, path_to, /*keeps_blob=*/metadata_only); +} + void MetadataStorageFromPlainRewritableObjectStorageTransaction::moveFile(const std::string & path_from, const std::string & path_to) { - uncommitted_state.useDirectory(normalizePath(path_from).parent_path()); - uncommitted_state.useDirectory(normalizePath(path_to).parent_path()); + planFileMove(normalizePath(path_from), normalizePath(path_to)); operations.addOperation(std::make_unique( /*replaceable=*/false, @@ -562,8 +694,7 @@ void MetadataStorageFromPlainRewritableObjectStorageTransaction::moveFile(const void MetadataStorageFromPlainRewritableObjectStorageTransaction::replaceFile(const std::string & path_from, const std::string & path_to) { - uncommitted_state.useDirectory(normalizePath(path_from).parent_path()); - uncommitted_state.useDirectory(normalizePath(path_to).parent_path()); + planFileMove(normalizePath(path_from), normalizePath(path_to)); operations.addOperation(std::make_unique( /*replaceable=*/true, @@ -600,7 +731,21 @@ ObjectStorageKey MetadataStorageFromPlainRewritableObjectStorageTransaction::gen } if (const auto directory_remote_info = uncommitted_state.getDirectoryRemoteInfo(parent_path)) - return ObjectStorageKey::createAsAbsolute(metadata_storage.layout->constructFileObjectKey(directory_remote_info->remote_path, normalized_path.filename())); + { + const auto file_name = normalized_path.filename().string(); + + /// In a directory with the explicit file list the blob names are random: a new file must not clobber the blob + /// of a removed file that is still linked from elsewhere. The same applies to rewriting a file whose blob is shared. + bool use_random_name = directory_remote_info->has_explicit_file_list; + if (!use_random_name) + if (const auto it = directory_remote_info->files.find(file_name); it != directory_remote_info->files.end()) + use_random_name = uncommitted_state.getSnapshot().getBlobLinkCount(getBlobKey(*directory_remote_info, file_name, it->second)) > 1; + + auto blob_key = getDefaultBlobKey(directory_remote_info->remote_path, use_random_name ? getRandomASCIIString(32) : file_name); + auto object_key = ObjectStorageKey::createAsAbsolute(metadata_storage.layout->constructBlobObjectKey(blob_key)); + generated_blob_keys[normalized_path.string()] = std::move(blob_key); + return object_key; + } throw Exception(ErrorCodes::LOGICAL_ERROR, "Directory '{}' does not exist", parent_path.string()); } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h index dc281e607536..20931c8403c4 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -14,7 +15,7 @@ namespace DB { -/** Stores data in immutable files, but allows atomic directory renames, which is suitable for MergeTree tables. +/** Stores data in immutable files, but allows atomic directory renames and hard links, which is suitable for MergeTree tables. * * The structure in object storage is as follows: * - every directory, regardless of its name and depth, is stored in a randomly-named directory at root; @@ -35,6 +36,27 @@ namespace DB * /__meta/xelohvynszqqinrvcygwzpdwvsklbxkk/prefix.path, contents: /hello/ * /aaealinyzgdzycgcnpgaapdssrjirnnr/test2.txt * /gfkoqxvyhaasroiodbeurnftnwieiihy/test1.txt + * + * The `prefix.path` above is in the implicit form: the files of the directory are whatever blobs are stored under its prefix. + * To support hard links, a directory can be switched to the explicit form, where `prefix.path` also lists the files + * with the keys of their blobs (see `PrefixPath.h`), so a file can point to a blob under the prefix of another directory: + * + * /__meta/gfkoqxvyhaasroiodbeurnftnwieiihy/prefix.path, contents: + * /hello/world/ + * files: 2 + * link.txt aaealinyzgdzycgcnpgaapdssrjirnnr/test2.txt 42 + * test1.txt gfkoqxvyhaasroiodbeurnftnwieiihy/test1.txt 7 + * + * The number of links to every blob is maintained in memory (and recalculated on load): a blob is removed together with its last link. + * New blobs of a directory in the explicit form get random names, so that a new file cannot clobber the blob of a deleted file + * that is still linked from elsewhere. Directories that never had hard links stay in the implicit form, so the layout of a disk + * without hard links is unchanged. + * + * The explicit form cannot be read by servers older than the version that introduced it, so a disk that has it is not readable + * after a downgrade. Therefore the creation of hard links is off by default and has to be enabled by the `enable_hard_links` + * setting of the disk; with hard links disabled, `createHardLink` copies the blob, as it did before, and no directory ever + * switches to the explicit form. The explicit form is always *read*, so a disk written with hard links enabled stays usable + * after they are disabled again. */ class MetadataStorageFromPlainRewritableObjectStorage final : public IMetadataStorage { @@ -43,17 +65,18 @@ class MetadataStorageFromPlainRewritableObjectStorage final : public IMetadataSt void load(bool is_initial_load, bool do_not_load_unchanged_directories); public: - MetadataStorageFromPlainRewritableObjectStorage(ObjectStoragePtr object_storage_, std::string storage_path_prefix_); + MetadataStorageFromPlainRewritableObjectStorage(ObjectStoragePtr object_storage_, std::string storage_path_prefix_, bool hard_links_enabled_); MetadataStorageType getType() const override { return MetadataStorageType::PlainRewritable; } const std::string & getPath() const override { return storage_path_full; } - uint32_t getHardlinkCount(const std::string & /* path */) const override { return 0; } + uint32_t getHardlinkCount(const std::string & path) const override; bool supportsChmod() const override { return false; } bool supportsStat() const override { return false; } bool isReadOnly() const override { return false; } bool areBlobPathsRandom() const override { return false; } bool isPlain() const override { return true; } bool isWriteOnce() const override { return false; } + bool supportsHardLinks() const override { return hard_links_enabled; } MetadataTransactionPtr createTransaction() override; @@ -82,6 +105,9 @@ private: const std::shared_ptr metrics; const std::string storage_path_prefix; const std::string storage_path_full; + /// Real hard links require the explicit form of `prefix.path`, which older servers cannot read, + /// so they are enabled by the `enable_hard_links` setting of the disk. See the comment above. + const bool hard_links_enabled; std::mutex metadata_mutex; FsMetadata fs; @@ -100,6 +126,10 @@ protected: UncommittedState uncommitted_state; MetadataOperationsHolder operations; StoredObjects removed_objects; + /// Blob keys chosen by `generateObjectKeyForPath`, by normalized file path, for the files this transaction is going to create. + std::unordered_map generated_blob_keys; + + void planFileMove(const NormalizedPath & path_from, const NormalizedPath & path_to); public: explicit MetadataStorageFromPlainRewritableObjectStorageTransaction(MetadataStorageFromPlainRewritableObjectStorage & metadata_storage_); @@ -120,7 +150,6 @@ public: void removeDirectory(const std::string & path) override; void removeRecursive(const std::string & path, const ShouldRemoveObjectsPredicate & should_remove_objects) override; - /// Hard links are simulated using server-side copying. void createHardLink(const std::string & path_from, const std::string & path_to) override; void moveFile(const std::string & path_from, const std::string & path_to) override; void replaceFile(const std::string & path_from, const std::string & path_to) override; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp index 7199a17b15e0..34186cf9ceb1 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -45,6 +46,80 @@ namespace FailPoints extern const char plain_object_storage_copy_temp_target_file_fail_on_file_move[]; } +namespace +{ + +/// The logical path of a directory as it is written to `prefix.path`. +std::string getLogicalDirectoryPath(const NormalizedPath & directory) +{ + if (directory.empty()) + return "/"; + return directory.string() + "/"; +} + +/// Rewrites the `prefix.path` object of a directory with the given contents. +void writeDirectoryMetadata( + IObjectStorage & object_storage, + const PlainRewritableLayout & layout, + const NormalizedPath & directory, + const DirectoryRemoteInfo & info) +{ + auto metadata_object_key = layout.constructDirectoryObjectKey(info.remote_path); + LOG_TRACE( + getLogger("MetadataStorageFromPlainRewritableObjectStorage"), + "Rewriting metadata for directory '{}' with remote path='{}', {} files listed explicitly", + directory.string(), + metadata_object_key, + info.has_explicit_file_list ? info.files.size() : 0); + + auto buf = object_storage.writeObject( + StoredObject(metadata_object_key, directory.string()), + WriteMode::Rewrite, + /*object_attributes*/ std::nullopt, + /*buf_size*/ 128, + /*settings*/ getWriteSettings()); + + writeString(serializePrefixPath(getLogicalDirectoryPath(directory), info), *buf); + buf->finalize(); +} + +DirectoryRemoteInfo getDirectoryInfoOrThrow(const FsSnapshot & fs_tree, const NormalizedPath & directory) +{ + auto info = fs_tree.getDirectoryRemoteInfo(directory); + if (!info) + throw Exception(ErrorCodes::LOGICAL_ERROR, "Directory '{}' does not exist or is virtual", directory.string()); + return std::move(*info); +} + +/// The blob is removed only when its last link is gone. The link count is tracked only for blobs that are referenced, +/// so the count is not decremented for the removed blob: a new blob may appear under the same key later. +std::optional removeLinkAndGetBlobToRemove( + FsSnapshot & fs_tree, const PlainRewritableLayout & layout, const std::string & blob_key) +{ + if (fs_tree.getBlobLinkCount(blob_key) == 1) + return StoredObject(layout.constructBlobObjectKey(blob_key)); + + fs_tree.removeBlobLink(blob_key); + return std::nullopt; +} + +} + +bool isMetadataOnlyMove( + const FsSnapshot & fs_tree, + const DirectoryRemoteInfo & directory_from, + const DirectoryRemoteInfo & directory_to, + const std::string & blob_key_from, + const std::optional & blob_key_of_existing_target) +{ + /// A directory with the implicit file list cannot lose a file whose blob stays under its prefix (the blob would be discovered + /// as a file again), nor gain a file whose blob lives elsewhere. And a shared blob cannot be moved or overwritten. + return directory_from.has_explicit_file_list + || directory_to.has_explicit_file_list + || fs_tree.getBlobLinkCount(blob_key_from) > 1 + || (blob_key_of_existing_target && fs_tree.getBlobLinkCount(*blob_key_of_existing_target) > 1); +} + MetadataStorageFromPlainObjectStorageValidatePreconditionsOperation::MetadataStorageFromPlainObjectStorageValidatePreconditionsOperation( std::shared_ptr preconditions_, std::shared_ptr fs_tree_) @@ -157,14 +232,14 @@ MetadataStorageFromPlainObjectStorageMoveDirectoryOperation::MetadataStorageFrom } std::unique_ptr MetadataStorageFromPlainObjectStorageMoveDirectoryOperation::createWriteBuf( - const DirectoryRemoteInfo & remote_info, std::optional expected_content) + const DirectoryRemoteInfo & remote_info, std::optional expected_logical_path) { auto metadata_object_key = layout->constructDirectoryObjectKey(remote_info.remote_path); StoredObject metadata_object(metadata_object_key); - if (expected_content) + if (expected_logical_path) { - chassert(expected_content.value().ends_with('/')); + chassert(expected_logical_path.value().ends_with('/')); LockMemoryExceptionInThread temporarily_lock_exceptions; std::string data; @@ -173,13 +248,17 @@ std::unique_ptr MetadataStorageFromPlainObjectStorageMo auto read_buf = object_storage->readObject(metadata_object, read_settings); readStringUntilEOF(data, *read_buf); - if (data != expected_content.value()) + const auto parsed = parsePrefixPath(data); + if (normalizePath(parsed.logical_path) != normalizePath(expected_logical_path.value()) + || parsed.has_explicit_file_list != remote_info.has_explicit_file_list) throw Exception( ErrorCodes::INCORRECT_DATA, - "Incorrect data for object key {}, expected {}, got {}", + "Incorrect data for object key {}, expected logical path {} (explicit file list: {}), got {} (explicit file list: {})", metadata_object_key, - expected_content.value(), - data); + expected_logical_path.value(), + remote_info.has_explicit_file_list, + parsed.logical_path, + parsed.has_explicit_file_list); } auto write_buf = object_storage->writeObject( @@ -192,11 +271,12 @@ std::unique_ptr MetadataStorageFromPlainObjectStorageMo return write_buf; } -void MetadataStorageFromPlainObjectStorageMoveDirectoryOperation::rewriteSingleDirectory(const std::filesystem::path & from, const std::filesystem::path & to, WriteBuffer & buffer) +void MetadataStorageFromPlainObjectStorageMoveDirectoryOperation::rewriteSingleDirectory( + const std::filesystem::path & from, const std::filesystem::path & to, const DirectoryRemoteInfo & remote_info, WriteBuffer & buffer) { LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageMoveDirectoryOperation"), "Rewriting '{}' to '{}'", from, to); - writeString(to.string(), buffer); + writeString(serializePrefixPath(to.string(), remote_info), buffer); fiu_do_on(FailPoints::plain_object_storage_write_fail_on_directory_move, { @@ -236,10 +316,10 @@ void MetadataStorageFromPlainObjectStorageMoveDirectoryOperation::execute() continue; } - auto write_buf = createWriteBuf(remote_info.value(), /*expected_content*/validate_content ? std::make_optional(sub_path_from) : std::nullopt); + auto write_buf = createWriteBuf(remote_info.value(), /*expected_logical_path*/validate_content ? std::make_optional(sub_path_from) : std::nullopt); changed_paths.insert(sub_path_from); - rewriteSingleDirectory(sub_path_from, sub_path_to, *write_buf); + rewriteSingleDirectory(sub_path_from, sub_path_to, remote_info.value(), *write_buf); } fs_tree->moveDirectory(path_from, path_to); @@ -257,8 +337,8 @@ void MetadataStorageFromPlainObjectStorageMoveDirectoryOperation::undo() if (!changed_paths.contains(sub_path_from)) continue; - auto write_buf = createWriteBuf(remote_info.value(), /*expected_content*/std::nullopt); - rewriteSingleDirectory(sub_path_to, sub_path_from, *write_buf); + auto write_buf = createWriteBuf(remote_info.value(), /*expected_logical_path*/std::nullopt); + rewriteSingleDirectory(sub_path_to, sub_path_from, remote_info.value(), *write_buf); } } @@ -316,35 +396,89 @@ void MetadataStorageFromPlainObjectStorageRemoveDirectoryOperation::undo() /*object_attributes*/ std::nullopt, /*buf_size*/ 128, /*settings*/ DB::getWriteSettings()); - writeString(path.string(), *buf); + writeString(serializePrefixPath(path.string(), info), *buf); buf->finalize(); } MetadataStorageFromPlainObjectStorageWriteFileOperation::MetadataStorageFromPlainObjectStorageWriteFileOperation( std::string path_, StoredObject object_, + std::string blob_key_, std::shared_ptr fs_tree_, std::shared_ptr object_storage_, std::shared_ptr layout_, - std::shared_ptr metrics_) + std::shared_ptr metrics_, + StoredObjects & removed_objects_) : path(std::move(path_)) , object(std::move(object_)) + , blob_key(std::move(blob_key_)) , fs_tree(std::move(fs_tree_)) , object_storage(std::move(object_storage_)) , layout(std::move(layout_)) , metrics(std::move(metrics_)) + , removed_objects(removed_objects_) { chassert(metrics); } void MetadataStorageFromPlainObjectStorageWriteFileOperation::execute() { - LOG_TEST(getLogger("MetadataStorageFromPlainObjectStorageWriteFileOperation"), "Creating metadata for a file '{}', size: {}", path, object.bytes_size); + LOG_TEST(getLogger("MetadataStorageFromPlainObjectStorageWriteFileOperation"), "Creating metadata for a file '{}', size: {}, blob key: '{}'", path, object.bytes_size, blob_key); + + const auto normalized_path = normalizePath(path); + const auto directory = normalized_path.parent_path(); + const auto file_name = normalized_path.filename().string(); + + auto directory_info = getDirectoryInfoOrThrow(*fs_tree, directory); + const std::string default_blob_key = getDefaultBlobKey(directory_info.remote_path, file_name); + /// `generateObjectKeyForPath` chose the key: the default location `/` for an implicit + /// directory or a fresh name, and a random name when the file already exists in an explicit directory or shares + /// its blob (so a new file never clobbers a blob still linked from elsewhere). + const std::string new_blob_key = blob_key.empty() ? default_blob_key : blob_key; + + if (const auto it = directory_info.files.find(file_name); it != directory_info.files.end()) + { + const auto existing_blob_key = getBlobKey(directory_info, file_name, it->second); + /// Rewriting a file to a new blob drops the link to the old one; the old blob is removed if it was the last link. + /// When the key is reused (an in-place overwrite in the default location) the caller has already replaced the + /// blob's content, so there is nothing to remove and the link count is unchanged. + if (existing_blob_key != new_blob_key) + replaced_blob = removeLinkAndGetBlobToRemove(*fs_tree, *layout, existing_blob_key); - if (fs_tree->existsFile(path)) fs_tree->removeFile(path); + } - fs_tree->recordFile(path, {object.bytes_size, std::time(nullptr)}); + fs_tree->recordFile(path, FileRemoteInfo{.bytes_size = object.bytes_size, .last_modified = std::time(nullptr), .blob_key = new_blob_key}); + + /// An explicit directory does not list its blobs, so the new file must be added to `prefix.path`. A blob outside + /// of the default location cannot be discovered by listing either, so it forces the directory to the explicit form. + if (!directory_info.has_explicit_file_list && new_blob_key == default_blob_key) + return; + + previous_directory_info = directory_info; + fs_tree->markDirectoryExplicit(directory); + + prefix_path_written = true; + writeDirectoryMetadata(*object_storage, *layout, directory, getDirectoryInfoOrThrow(*fs_tree, directory)); +} + +void MetadataStorageFromPlainObjectStorageWriteFileOperation::undo() +{ + if (!prefix_path_written) + return; + + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageWriteFileOperation"), "Reversing the metadata rewrite for the directory of '{}'", path); + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path).parent_path(), previous_directory_info.value()); +} + +void MetadataStorageFromPlainObjectStorageWriteFileOperation::finalize() +{ + if (!replaced_blob) + return; + + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageWriteFileOperation"), "Removing the replaced blob '{}' of the file '{}'", replaced_blob->remote_path, path); + object_storage->removeObjectIfExists(*replaced_blob); + removed_objects.push_back(*replaced_blob); } MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation::MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation( @@ -381,9 +515,28 @@ void MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation::execute() throw Exception(ErrorCodes::FILE_DOESNT_EXIST, "File '{}' does not exist", path); } - const auto normalized_path_from = normalizePath(path); - const auto directory_remote_path_from = fs_tree->getDirectoryRemoteInfo(normalized_path_from.parent_path())->remote_path; - remote_source_path = layout->constructFileObjectKey(directory_remote_path_from, normalized_path_from.filename()); + const auto normalized_path = normalizePath(path); + const auto directory = normalized_path.parent_path(); + const auto file_name = normalized_path.filename().string(); + + auto directory_info = getDirectoryInfoOrThrow(*fs_tree, directory); + const auto blob_key = getBlobKey(directory_info, file_name, directory_info.files.at(file_name)); + + if (directory_info.has_explicit_file_list || fs_tree->getBlobLinkCount(blob_key) > 1) + { + /// The file is dropped from the explicit file list; the blob stays until the last link is gone, and is removed after the commit. + previous_directory_info = directory_info; + + fs_tree->removeFile(path); + blob_to_remove = removeLinkAndGetBlobToRemove(*fs_tree, *layout, blob_key); + fs_tree->markDirectoryExplicit(directory); + + prefix_path_written = true; + writeDirectoryMetadata(*object_storage, *layout, directory, getDirectoryInfoOrThrow(*fs_tree, directory)); + return; + } + + remote_source_path = layout->constructBlobObjectKey(blob_key); remote_tmp_path = layout->constructFileObjectKey(PlainRewritableLayout::ROOT_DIRECTORY_TOKEN, getRandomASCIIString(16)); copy_started = true; @@ -397,6 +550,13 @@ void MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation::execute() void MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation::undo() { + if (prefix_path_written) + { + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation"), "Reversing the metadata rewrite for the directory of '{}'", path); + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path).parent_path(), previous_directory_info.value()); + return; + } + if (!copy_started) return; @@ -408,10 +568,18 @@ void MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation::undo() void MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation::finalize() { - removed_objects.push_back(StoredObject(remote_source_path)); + if (blob_to_remove) + { + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation"), "Removing the blob '{}' of the unlinked file '{}'", blob_to_remove->remote_path, path); + object_storage->removeObjectIfExists(*blob_to_remove); + removed_objects.push_back(*blob_to_remove); + } if (copy_started) + { + removed_objects.push_back(StoredObject(remote_source_path)); object_storage->removeObjectIfExists(StoredObject(remote_tmp_path)); + } } MetadataStorageFromPlainObjectStorageCopyFileOperation::MetadataStorageFromPlainObjectStorageCopyFileOperation( @@ -437,28 +605,47 @@ void MetadataStorageFromPlainObjectStorageCopyFileOperation::execute() if (!fs_tree->existsFile(path_from)) throw Exception(ErrorCodes::FILE_DOESNT_EXIST, "Metadata object for the source path '{}' does not exist", path_from); - else if (!fs_tree->existsDirectory(path_to.parent_path())) - throw Exception(ErrorCodes::DIRECTORY_DOESNT_EXIST, "Directory '{}' does not exist", path_to.parent_path()); - else if (!fs_tree->getDirectoryRemoteInfo(path_to.parent_path())) - throw Exception(ErrorCodes::LOGICAL_ERROR, "Directory '{}' is virtual", path_to.parent_path()); - else if (fs_tree->existsFile(path_to)) + + if (!fs_tree->existsDirectory(normalizePath(path_to).parent_path())) + throw Exception(ErrorCodes::DIRECTORY_DOESNT_EXIST, "Target directory '{}' does not exist", normalizePath(path_to).parent_path().string()); + + if (fs_tree->existsFile(path_to)) throw Exception(ErrorCodes::FILE_ALREADY_EXISTS, "Target file '{}' already exists", path_to); const auto normalized_path_from = normalizePath(path_from); - const auto directory_remote_path_from = fs_tree->getDirectoryRemoteInfo(normalized_path_from.parent_path())->remote_path; - remote_path_from = layout->constructFileObjectKey(directory_remote_path_from, normalized_path_from.filename()); + const auto directory_info_from = getDirectoryInfoOrThrow(*fs_tree, normalized_path_from.parent_path()); + const auto & file_info_from = directory_info_from.files.at(normalized_path_from.filename()); + /// The source may belong to a directory in the explicit form, written while hard links were enabled. + remote_path_from = layout->constructBlobObjectKey(getBlobKey(directory_info_from, normalized_path_from.filename(), file_info_from)); const auto normalized_path_to = normalizePath(path_to); - const auto directory_remote_path_to = fs_tree->getDirectoryRemoteInfo(normalized_path_to.parent_path())->remote_path; - remote_path_to = layout->constructFileObjectKey(directory_remote_path_to, normalized_path_to.filename()); + const auto directory_to = normalized_path_to.parent_path(); + const auto directory_info_to = getDirectoryInfoOrThrow(*fs_tree, directory_to); + /// The copy is a new blob at the default location, so it needs no explicit file list of its own. + remote_path_to = layout->constructFileObjectKey(directory_info_to.remote_path, normalized_path_to.filename()); copy_attempted = true; object_storage->copyObject(StoredObject(remote_path_from), StoredObject(remote_path_to), getReadSettings(), getWriteSettings()); - fs_tree->recordFile(path_to, fs_tree->getFileRemoteInfo(path_from).value()); + fs_tree->recordFile(path_to, FileRemoteInfo{.bytes_size = file_info_from.bytes_size, .last_modified = file_info_from.last_modified, .blob_key = {}}); + + /// A directory in the explicit form does not list its blobs, so the copy has to be added to `prefix.path`. + /// This happens only on a disk that had hard links enabled before. + if (!directory_info_to.has_explicit_file_list) + return; + + previous_directory_info = directory_info_to; + prefix_path_written = true; + writeDirectoryMetadata(*object_storage, *layout, directory_to, getDirectoryInfoOrThrow(*fs_tree, directory_to)); } void MetadataStorageFromPlainObjectStorageCopyFileOperation::undo() { + if (prefix_path_written) + { + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageCopyFileOperation"), "Reversing the metadata rewrite for the directory of '{}'", path_to); + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info.value()); + } + if (!copy_attempted) return; @@ -471,6 +658,61 @@ void MetadataStorageFromPlainObjectStorageCopyFileOperation::undo() object_storage->removeObjectIfExists(StoredObject(remote_path_to)); } +MetadataStorageFromPlainObjectStorageHardLinkOperation::MetadataStorageFromPlainObjectStorageHardLinkOperation( + std::filesystem::path path_from_, + std::filesystem::path path_to_, + std::shared_ptr fs_tree_, + std::shared_ptr object_storage_, + std::shared_ptr layout_, + std::shared_ptr metrics_) + : path_from(std::move(path_from_)) + , path_to(std::move(path_to_)) + , fs_tree(std::move(fs_tree_)) + , object_storage(std::move(object_storage_)) + , layout(std::move(layout_)) + , metrics(std::move(metrics_)) +{ + chassert(metrics); +} + +void MetadataStorageFromPlainObjectStorageHardLinkOperation::execute() +{ + LOG_TEST(getLogger("MetadataStorageFromPlainObjectStorageHardLinkOperation"), "Creating hard link '{}' to '{}'", path_to, path_from); + + if (!fs_tree->existsFile(path_from)) + throw Exception(ErrorCodes::FILE_DOESNT_EXIST, "Metadata object for the source path '{}' does not exist", path_from); + else if (!fs_tree->existsDirectory(path_to.parent_path())) + throw Exception(ErrorCodes::DIRECTORY_DOESNT_EXIST, "Directory '{}' does not exist", path_to.parent_path()); + else if (!fs_tree->getDirectoryRemoteInfo(path_to.parent_path())) + throw Exception(ErrorCodes::LOGICAL_ERROR, "Directory '{}' is virtual", path_to.parent_path()); + else if (fs_tree->existsFile(path_to)) + throw Exception(ErrorCodes::FILE_ALREADY_EXISTS, "Target file '{}' already exists", path_to); + + const auto normalized_path_from = normalizePath(path_from); + const auto directory_info_from = getDirectoryInfoOrThrow(*fs_tree, normalized_path_from.parent_path()); + const auto & file_info_from = directory_info_from.files.at(normalized_path_from.filename()); + const auto blob_key = getBlobKey(directory_info_from, normalized_path_from.filename(), file_info_from); + + const auto directory_to = normalizePath(path_to).parent_path(); + previous_directory_info = getDirectoryInfoOrThrow(*fs_tree, directory_to); + + fs_tree->recordFile(path_to, FileRemoteInfo{.bytes_size = file_info_from.bytes_size, .last_modified = file_info_from.last_modified, .blob_key = blob_key}); + fs_tree->addBlobLink(blob_key); + fs_tree->markDirectoryExplicit(directory_to); + + prefix_path_written = true; + writeDirectoryMetadata(*object_storage, *layout, directory_to, getDirectoryInfoOrThrow(*fs_tree, directory_to)); +} + +void MetadataStorageFromPlainObjectStorageHardLinkOperation::undo() +{ + if (!prefix_path_written) + return; + + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageHardLinkOperation"), "Reversing the hard link '{}' to '{}'", path_to, path_from); + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info.value()); +} + MetadataStorageFromPlainObjectStorageMoveFileOperation::MetadataStorageFromPlainObjectStorageMoveFileOperation( bool replaceable_, std::filesystem::path path_from_, @@ -510,14 +752,55 @@ void MetadataStorageFromPlainObjectStorageMoveFileOperation::execute() const auto normalized_path_from = normalizePath(path_from); const auto normalized_path_to = normalizePath(path_to); - const auto directory_remote_path_from = fs_tree->getDirectoryRemoteInfo(normalized_path_from.parent_path())->remote_path; - const auto directory_remote_path_to = fs_tree->getDirectoryRemoteInfo(normalized_path_to.parent_path())->remote_path; + const auto directory_from = normalized_path_from.parent_path(); + const auto directory_to = normalized_path_to.parent_path(); + const auto directory_info_from = getDirectoryInfoOrThrow(*fs_tree, directory_from); + const auto directory_info_to = getDirectoryInfoOrThrow(*fs_tree, directory_to); - remote_path_from = layout->constructFileObjectKey(directory_remote_path_from, normalized_path_from.filename()); - remote_path_to = layout->constructFileObjectKey(directory_remote_path_to, normalized_path_to.filename()); + file_from_remote_info = directory_info_from.files.at(normalized_path_from.filename()); + const auto blob_key_from = getBlobKey(directory_info_from, normalized_path_from.filename(), file_from_remote_info.value()); + + std::optional blob_key_of_existing_target; + if (const auto it = directory_info_to.files.find(normalized_path_to.filename()); it != directory_info_to.files.end()) + blob_key_of_existing_target = getBlobKey(directory_info_to, normalized_path_to.filename(), it->second); + + if (isMetadataOnlyMove(*fs_tree, directory_info_from, directory_info_to, blob_key_from, blob_key_of_existing_target)) + { + metadata_only_move = true; + previous_directory_info_from = directory_info_from; + previous_directory_info_to = directory_info_to; + + if (blob_key_of_existing_target) + { + if (!replaceable) + throw Exception(ErrorCodes::FILE_ALREADY_EXISTS, "Target file '{}' already exists", path_to); + + fs_tree->removeFile(path_to); + replaced_blob = removeLinkAndGetBlobToRemove(*fs_tree, *layout, *blob_key_of_existing_target); + } + + /// The blob stays in place, only the file lists change. + fs_tree->removeFile(path_from); + fs_tree->recordFile(path_to, FileRemoteInfo{.bytes_size = file_from_remote_info->bytes_size, .last_modified = file_from_remote_info->last_modified, .blob_key = blob_key_from}); + fs_tree->markDirectoryExplicit(directory_to); + fs_tree->markDirectoryExplicit(directory_from); + + prefix_path_written_to = true; + writeDirectoryMetadata(*object_storage, *layout, directory_to, getDirectoryInfoOrThrow(*fs_tree, directory_to)); + + if (directory_from != directory_to) + { + prefix_path_written_from = true; + writeDirectoryMetadata(*object_storage, *layout, directory_from, getDirectoryInfoOrThrow(*fs_tree, directory_from)); + } + + return; + } + + remote_path_from = layout->constructFileObjectKey(directory_info_from.remote_path, normalized_path_from.filename()); + remote_path_to = layout->constructFileObjectKey(directory_info_to.remote_path, normalized_path_to.filename()); tmp_remote_path_from = layout->constructFileObjectKey(PlainRewritableLayout::ROOT_DIRECTORY_TOKEN, getRandomASCIIString(16)); tmp_remote_path_to = layout->constructFileObjectKey(PlainRewritableLayout::ROOT_DIRECTORY_TOKEN, getRandomASCIIString(16)); - file_from_remote_info = fs_tree->getFileRemoteInfo(path_from).value(); const auto read_settings = getReadSettingsForMetadata(); const auto write_settings = getWriteSettingsForMetadata(); @@ -575,6 +858,19 @@ void MetadataStorageFromPlainObjectStorageMoveFileOperation::execute() void MetadataStorageFromPlainObjectStorageMoveFileOperation::undo() { + if (metadata_only_move) + { + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageMoveFileOperation"), "Reversing the metadata rewrite for the move from '{}' to '{}'", path_from, path_to); + + if (prefix_path_written_to) + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info_to.value()); + + if (prefix_path_written_from) + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_from).parent_path(), previous_directory_info_from.value()); + + return; + } + const auto read_settings = getReadSettings(); const auto write_settings = getWriteSettings(); @@ -615,6 +911,18 @@ void MetadataStorageFromPlainObjectStorageMoveFileOperation::undo() void MetadataStorageFromPlainObjectStorageMoveFileOperation::finalize() { + if (metadata_only_move) + { + if (replaced_blob) + { + LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageMoveFileOperation"), "Removing the blob '{}' of the replaced file '{}'", replaced_blob->remote_path, path_to); + object_storage->removeObjectIfExists(*replaced_blob); + removed_objects.push_back(*replaced_blob); + } + + return; + } + removed_objects.push_back(StoredObject(remote_path_from)); if (moved_existing_source_file) @@ -657,7 +965,40 @@ void MetadataStorageFromPlainObjectStorageRemoveRecursiveOperation::execute() move_tried = true; move_to_tmp_op->execute(); - subtree_remote_info = fs_tree->getSubtreeRemoteInfo(tmp_path); + for (const auto & [subdir, remote_info] : fs_tree->getSubtreeRemoteInfo(tmp_path)) + { + auto subdir_path = tmp_path / subdir; + + if (!remote_info.has_value()) + { + LOG_TRACE(log, "Directory '{}' is virtual, will not cleanup remote", subdir_path); + continue; + } + + LOG_TRACE(log, "Removing directory '{}'", subdir_path); + + auto metadata_object_key = layout->constructDirectoryObjectKey(remote_info->remote_path); + objects_to_remove.emplace_back(metadata_object_key, path); + + /// We also need to remove all files inside each of the subdirectories, but only the blobs that are not shared with files outside. + for (const auto & [filename, file_info] : remote_info->files) + { + auto file_path = subdir_path / filename; + const auto blob_key = getBlobKey(remote_info.value(), filename, file_info); + + if (fs_tree->getBlobLinkCount(blob_key) == 1) + { + LOG_TRACE(log, "Removing file '{}'", file_path); + objects_to_remove.emplace_back(layout->constructBlobObjectKey(blob_key), file_path); + } + else + { + LOG_TRACE(log, "Keeping the blob of the file '{}', it is shared with other files", file_path); + fs_tree->removeBlobLink(blob_key); + } + } + } + fs_tree->removeDirectory(tmp_path); } } @@ -675,34 +1016,6 @@ void MetadataStorageFromPlainObjectStorageRemoveRecursiveOperation::finalize() if (!move_tried) return; - StoredObjects objects_to_remove; - for (const auto & [subdir, remote_info] : subtree_remote_info) - { - auto subdir_path = tmp_path / subdir; - - if (!remote_info.has_value()) - { - LOG_TRACE(log, "Directory '{}' is virtual, will not cleanup remote", subdir_path); - continue; - } - - LOG_TRACE(log, "Removing directory '{}'", subdir_path); - - auto metadata_object_key = layout->constructDirectoryObjectKey(remote_info->remote_path); - objects_to_remove.emplace_back(metadata_object_key, path); - - /// We also need to remove all files inside each of the subdirectories. - for (const auto & [filename, file_info] : remote_info->files) - { - auto file_path = subdir_path / filename; - - LOG_TRACE(log, "Removing file '{}'", file_path); - - auto file_object_key = layout->constructFileObjectKey(remote_info->remote_path, filename); - objects_to_remove.emplace_back(file_object_key, file_path); - } - } - object_storage->removeObjectsIfExist(objects_to_remove); removed_objects.append_range(objects_to_remove); } diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h index 937505133048..79a76e6b8d47 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h @@ -14,6 +14,15 @@ namespace DB { +/// Whether a file move must be done by changing the file lists of the directories instead of copying the blob. +/// Used both when the transaction is planned and when it is committed. +bool isMetadataOnlyMove( + const FsSnapshot & fs_tree, + const DirectoryRemoteInfo & directory_from, + const DirectoryRemoteInfo & directory_to, + const std::string & blob_key_from, + const std::optional & blob_key_of_existing_target); + class MetadataStorageFromPlainObjectStorageValidatePreconditionsOperation final : public IMetadataOperation { private: @@ -68,8 +77,8 @@ private: std::unordered_map> from_tree_info; std::unordered_set changed_paths; - std::unique_ptr createWriteBuf(const DirectoryRemoteInfo & remote_info, std::optional expected_content); - void rewriteSingleDirectory(const std::filesystem::path & from, const std::filesystem::path & to, WriteBuffer & buffer); + std::unique_ptr createWriteBuf(const DirectoryRemoteInfo & remote_info, std::optional expected_logical_path); + void rewriteSingleDirectory(const std::filesystem::path & from, const std::filesystem::path & to, const DirectoryRemoteInfo & remote_info, WriteBuffer & buffer); public: MetadataStorageFromPlainObjectStorageMoveDirectoryOperation( @@ -108,26 +117,40 @@ public: void undo() override; }; +/// Records a file whose blob has already been written. +/// The blob key is relative to the common key prefix; an empty key means the default location, `/`. +/// A blob at the default location only fits a directory with the implicit file list; a directory with the explicit file list +/// gets its `prefix.path` rewritten with the new file, and a blob outside of the default location switches the directory to that form. class MetadataStorageFromPlainObjectStorageWriteFileOperation final : public IMetadataOperation { private: const std::filesystem::path path; const StoredObject object; + const std::string blob_key; const std::shared_ptr fs_tree; const std::shared_ptr object_storage; const std::shared_ptr layout; const std::shared_ptr metrics; + StoredObjects & removed_objects; + + std::optional previous_directory_info; + bool prefix_path_written = false; + std::optional replaced_blob; public: MetadataStorageFromPlainObjectStorageWriteFileOperation( std::string path_, StoredObject object_, + std::string blob_key_, std::shared_ptr fs_tree_, std::shared_ptr object_storage_, std::shared_ptr layout_, - std::shared_ptr metrics_); + std::shared_ptr metrics_, + StoredObjects & removed_objects_); void execute() override; + void undo() override; + void finalize() override; }; class MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation final : public IMetadataOperation @@ -141,11 +164,20 @@ private: const std::shared_ptr metrics; StoredObjects & removed_objects; + /// A file of a directory with the implicit file list, whose blob is not shared, is removed together with its blob: + /// otherwise the blob would be discovered as a file again when the metadata is loaded. To be able to undo the removal, + /// the blob is first copied to a temporary location. std::filesystem::path remote_source_path; std::filesystem::path remote_tmp_path; bool copy_started = false; bool remove_started = false; + /// Otherwise the file is removed from the explicit file list of the directory (switching the directory to that form + /// if needed), and the blob is removed after the commit if this was its last link. + std::optional previous_directory_info; + bool prefix_path_written = false; + std::optional blob_to_remove; + public: MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation( std::filesystem::path path_, @@ -161,6 +193,8 @@ public: void finalize() override; }; +/// Duplicates the blob of the source file. Used when hard links are disabled for the disk, so that the metadata +/// stays in the form that older servers can read (see `MetadataStorageFromPlainRewritableObjectStorage`). /// Throws an exception if path_to_ already exists. class MetadataStorageFromPlainObjectStorageCopyFileOperation final : public IMetadataOperation { @@ -175,6 +209,8 @@ private: std::filesystem::path remote_path_from; std::filesystem::path remote_path_to; bool copy_attempted = false; + std::optional previous_directory_info; + bool prefix_path_written = false; public: MetadataStorageFromPlainObjectStorageCopyFileOperation( @@ -189,10 +225,43 @@ public: void undo() override; }; +/// Creates a hard link: a file in the target directory that shares the blob of the source file. +/// The target directory is switched to the explicit file list form, and the number of links to the blob is incremented. +/// Throws an exception if path_to_ already exists. +class MetadataStorageFromPlainObjectStorageHardLinkOperation final : public IMetadataOperation +{ +private: + const std::filesystem::path path_from; + const std::filesystem::path path_to; + const std::shared_ptr fs_tree; + const std::shared_ptr object_storage; + const std::shared_ptr layout; + const std::shared_ptr metrics; + + std::optional previous_directory_info; + bool prefix_path_written = false; + +public: + MetadataStorageFromPlainObjectStorageHardLinkOperation( + std::filesystem::path path_from_, + std::filesystem::path path_to_, + std::shared_ptr fs_tree_, + std::shared_ptr object_storage_, + std::shared_ptr layout_, + std::shared_ptr metrics_); + + void execute() override; + void undo() override; +}; + /** * @brief MetadataStorageFromPlainObjectStorageMoveFileOperation move file from {path_from, remote_path_from} to {path_to, remote_path_to}. * If `replacable` is enabled, the target file will be replaced if exists. If disabled, the target file must not exist. * Both source and target files must not be directories. + * + * When both directories have the implicit file list and the blobs involved are not shared, the blob is moved by copying. + * Otherwise the move only changes the file lists of the two directories (switching them to the explicit form if needed), + * and the blob stays where it is. */ class MetadataStorageFromPlainObjectStorageMoveFileOperation final : public IMetadataOperation { @@ -206,6 +275,13 @@ private: const std::shared_ptr metrics; StoredObjects & removed_objects; + bool metadata_only_move{false}; + std::optional previous_directory_info_from; + std::optional previous_directory_info_to; + bool prefix_path_written_from{false}; + bool prefix_path_written_to{false}; + std::optional replaced_blob; + std::filesystem::path remote_path_from; std::filesystem::path remote_path_to; std::filesystem::path tmp_remote_path_from; @@ -264,7 +340,8 @@ private: std::filesystem::path tmp_path; std::unique_ptr move_to_tmp_op; - std::unordered_map> subtree_remote_info; + /// The metadata objects of the removed directories and the blobs whose last links were inside the removed subtree. + StoredObjects objects_to_remove; bool move_tried = false; public: diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.cpp index e7a67f2ed324..b18a67d7e2f0 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.cpp @@ -34,6 +34,11 @@ std::string PlainRewritableLayout::constructFileObjectKey(const std::string & di return object_storage_common_key_prefix / directory_remote_path / file_name; } +std::string PlainRewritableLayout::constructBlobObjectKey(const std::string & blob_key) const +{ + return object_storage_common_key_prefix / blob_key; +} + std::string PlainRewritableLayout::constructDirectoryObjectKey(const std::string & directory_remote_path) const { return object_storage_common_key_prefix / METADATA_DIRECTORY_TOKEN / directory_remote_path / PREFIX_PATH_FILE_NAME; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.h index c1d52673f564..57aafe63b6a5 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/PlainRewritableLayout.h @@ -20,6 +20,8 @@ public: std::string constructRootFilesDirectoryKey() const; std::string constructFilesDirectoryKey(const std::string & directory_remote_path) const; std::string constructFileObjectKey(const std::string & directory_remote_path, const std::string & file_name) const; + /// Object key of a blob given its key relative to the common key prefix (see `FileRemoteInfo::blob_key`). + std::string constructBlobObjectKey(const std::string & blob_key) const; std::string constructDirectoryObjectKey(const std::string & directory_remote_path) const; std::optional> parseFileObjectKey(const std::string & key) const; diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.cpp index eaac21eb63b2..32bdd3f8ae10 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.cpp @@ -149,6 +149,122 @@ void UncommittedState::moveDirectory(const std::string & path_from, const std::s tx_snapshot->moveDirectory(path_from, path_to); } +void UncommittedState::recordCreatedFile(const std::string & path, const std::string & blob_key) +{ + const auto normalized_path = normalizePath(path); + const auto directory = tx_snapshot->getDirectoryRemoteInfo(normalized_path.parent_path()); + if (!directory) + return; + + const auto file_name = normalized_path.filename().string(); + FileRemoteInfo info{.blob_key = blob_key}; + const auto new_blob_key = getBlobKey(*directory, file_name, info); + + /// Rewriting a file: it stops referencing its previous blob, unless the blob is reused in place. + if (const auto it = directory->files.find(file_name); it != directory->files.end()) + { + const auto previous_blob_key = getBlobKey(*directory, file_name, it->second); + /// The count is tracked only for the blobs that have more than one link, so the last link is not subtracted. + if (previous_blob_key != new_blob_key && tx_snapshot->getBlobLinkCount(previous_blob_key) > 1) + tx_snapshot->removeBlobLink(previous_blob_key); + + tx_snapshot->removeFile(path); + } + + tx_snapshot->recordFile(path, std::move(info)); + + /// A blob outside of the default location cannot be found by listing the prefix of the directory, so the commit + /// switches the directory to the explicit file list. Without recording that here, the next blob of this directory + /// would be placed at the default location again - which is exactly the blob that the file this one was + /// hard-linked from is keeping alive. + if (new_blob_key != getDefaultBlobKey(directory->remote_path, file_name)) + tx_snapshot->markDirectoryExplicit(normalized_path.parent_path()); +} + +void UncommittedState::recordHardLink(const std::string & path_from, const std::string & path_to) +{ + const auto normalized_path_from = normalizePath(path_from); + const auto normalized_path_to = normalizePath(path_to); + + /// The target directory gains a file whose blob is stored under the prefix of another directory. + markDirectoryExplicit(normalized_path_to.parent_path()); + + const auto directory_from = tx_snapshot->getDirectoryRemoteInfo(normalized_path_from.parent_path()); + if (!directory_from) + return; + + const auto file_name_from = normalized_path_from.filename().string(); + const auto file_from = directory_from->files.find(file_name_from); + if (file_from == directory_from->files.end()) + return; + + auto info = file_from->second; + info.blob_key = getBlobKey(*directory_from, file_name_from, file_from->second); + tx_snapshot->addBlobLink(info.blob_key); + + /// A hard link over something that already exists is rejected by the commit, which is where the error belongs. + if (!tx_snapshot->getDirectoryRemoteInfo(normalized_path_to.parent_path()) + || tx_snapshot->existsFile(path_to) + || tx_snapshot->existsDirectory(path_to)) + return; + + tx_snapshot->recordFile(path_to, std::move(info)); +} + +void UncommittedState::recordMovedFile(const std::string & path_from, const std::string & path_to, bool keeps_blob) +{ + const auto normalized_path_from = normalizePath(path_from); + const auto normalized_path_to = normalizePath(path_to); + if (normalized_path_from == normalized_path_to) + return; + + const auto directory_from = tx_snapshot->getDirectoryRemoteInfo(normalized_path_from.parent_path()); + const auto directory_to = tx_snapshot->getDirectoryRemoteInfo(normalized_path_to.parent_path()); + if (!directory_from || !directory_to) + return; + + const auto file_name_from = normalized_path_from.filename().string(); + const auto file_name_to = normalized_path_to.filename().string(); + + const auto file_from = directory_from->files.find(file_name_from); + if (file_from == directory_from->files.end()) + return; + + /// A move over a directory is rejected by the commit, which is where the error belongs. + if (tx_snapshot->existsDirectory(path_to)) + return; + + auto info = file_from->second; + /// A metadata-only move leaves the blob where it is; otherwise the blob is copied to the default location + /// in the target directory. + info.blob_key = keeps_blob + ? getBlobKey(*directory_from, file_name_from, file_from->second) + : getDefaultBlobKey(directory_to->remote_path, file_name_to); + + /// The replaced file stops referencing its blob. The blob itself survives only when it is shared: + /// a move that is not metadata-only overwrites it. + if (const auto file_to = directory_to->files.find(file_name_to); file_to != directory_to->files.end()) + { + const auto replaced_blob_key = getBlobKey(*directory_to, file_name_to, file_to->second); + /// The count is tracked only for the blobs that have more than one link, so the last link is not subtracted. + if (tx_snapshot->getBlobLinkCount(replaced_blob_key) > 1) + tx_snapshot->removeBlobLink(replaced_blob_key); + + tx_snapshot->removeFile(path_to); + } + + tx_snapshot->removeFile(path_from); + tx_snapshot->recordFile(path_to, std::move(info)); +} + +void UncommittedState::markDirectoryExplicit(const std::string & path) +{ + if (!tx_snapshot->getDirectoryRemoteInfo(path)) + return; + + tx_snapshot->markDirectoryExplicit(path); +} + std::optional UncommittedState::getDirectoryRemoteInfo(const std::string & path) const { return tx_snapshot->getDirectoryRemoteInfo(path); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.h index 6da3407f7440..5dc9def6b16c 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/Transactions/UncommittedState.h @@ -20,7 +20,21 @@ public: void removeDirectory(const std::string & path); void moveDirectory(const std::string & path_from, const std::string & path_to); + /// The file operations that add a file to a directory are replayed on the uncommitted state, because the choice + /// of the object key for the next blob of a directory depends on them: a blob must never be written to the key + /// of a blob that another file is going to keep alive. Removals are not replayed - a file that is still visible + /// only makes the choice of the key more conservative. + void recordCreatedFile(const std::string & path, const std::string & blob_key); + /// A hard link: the target references the blob of the source, so the blob becomes shared and the target directory + /// gets an explicit file list. + void recordHardLink(const std::string & path_from, const std::string & path_to); + /// A file move (or replace). `keeps_blob` tells that the blob stays where it is (a metadata-only move) instead of + /// being copied to the default location in the target directory. + void recordMovedFile(const std::string & path_from, const std::string & path_to, bool keeps_blob); + void markDirectoryExplicit(const std::string & path); + std::optional getDirectoryRemoteInfo(const std::string & path) const; + const FsSnapshot & getSnapshot() const { return *tx_snapshot; } std::shared_ptr getTxPreconditions() const; private: diff --git a/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp b/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp index 28da9e2c1f03..a50158731332 100644 --- a/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp +++ b/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -27,6 +28,9 @@ using namespace DB; class MetadataPlainRewritableDiskTest : public testing::Test { public: + /// The `enable_hard_links` setting of the disk. Set it before the first `getMetadataStorage` call of a test. + bool hard_links_enabled = true; + void SetUp() override { if (!initialized) @@ -51,7 +55,7 @@ public: { std::unique_lock lock(active_metadatas_mutex); auto object_storage = active_object_storages.at(key_prefix); - active_metadatas[key_prefix] = std::make_shared(object_storage, ""); + active_metadatas[key_prefix] = std::make_shared(object_storage, "", hard_links_enabled); return active_metadatas.at(key_prefix); } @@ -79,7 +83,7 @@ private: fs::remove_all("./" + key_prefix); LocalObjectStorageSettings settings("test", "./" + key_prefix, /*read_only_=*/false); auto object_storage = std::make_shared(std::move(settings)); - auto metadata_storage = std::make_shared(object_storage, ""); + auto metadata_storage = std::make_shared(object_storage, "", hard_links_enabled); active_metadatas.emplace(key_prefix, metadata_storage); active_object_storages.emplace(key_prefix, object_storage); @@ -1291,16 +1295,32 @@ TEST_F(MetadataPlainRewritableDiskTest, CreateHardLink) EXPECT_TRUE(metadata->existsFile("/A/f1")); EXPECT_TRUE(metadata->existsFile("/A/f2")); + EXPECT_EQ(metadata->getHardlinkCount("/A/f1"), 1); + EXPECT_EQ(metadata->getHardlinkCount("/A/f2"), 1); + + /// The link shares the blob of the original file. + EXPECT_EQ(metadata->getStorageObjects("/A/f1").front().remote_path, metadata->getStorageObjects("/A/f2").front().remote_path); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("/A/f2").front().remote_path), "f1"); metadata = restartMetadataStorage("CreateHardLink"); EXPECT_TRUE(metadata->existsDirectory("/A")); EXPECT_TRUE(metadata->existsFile("/A/f1")); EXPECT_TRUE(metadata->existsFile("/A/f2")); + EXPECT_EQ(metadata->getHardlinkCount("/A/f1"), 1); + EXPECT_EQ(metadata->getStorageObjects("/A/f1").front().remote_path, metadata->getStorageObjects("/A/f2").front().remote_path); + EXPECT_EQ(metadata->getFileSize("/A/f2"), 2u); EXPECT_EQ(listAllBlobs("CreateHardLink"), std::vector({ - "./CreateHardLink/__meta/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/prefix.path", /// /A - "./CreateHardLink/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/f1", /// /A/f1 - "./CreateHardLink/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/f2" /// /A/f2 + "./CreateHardLink/__meta/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/prefix.path", /// /A, with the explicit file list + "./CreateHardLink/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/f1", /// /A/f1 and /A/f2 })); + + const auto prefix_path = parsePrefixPath(readObject(object_storage, "./CreateHardLink/__meta/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/prefix.path")); + EXPECT_TRUE(prefix_path.has_explicit_file_list); + ASSERT_EQ(prefix_path.files.size(), 2u); + EXPECT_EQ(prefix_path.files[0].name, "f1"); + EXPECT_EQ(prefix_path.files[0].blob_key, "faefxnlkbtfqgxcbfqfjtztsocaqrnqn/f1"); + EXPECT_EQ(prefix_path.files[1].name, "f2"); + EXPECT_EQ(prefix_path.files[1].blob_key, "faefxnlkbtfqgxcbfqfjtztsocaqrnqn/f1"); } TEST_F(MetadataPlainRewritableDiskTest, CreateHardLinkUndo) @@ -1362,10 +1382,14 @@ TEST_F(MetadataPlainRewritableDiskTest, CreateHardLinkUndo) EXPECT_TRUE(metadata->existsDirectory("/A")); EXPECT_TRUE(metadata->existsFile("/A/f1")); EXPECT_FALSE(metadata->existsFile("/A/f2")); + EXPECT_EQ(metadata->getHardlinkCount("/A/f1"), 0); EXPECT_EQ(listAllBlobs("CreateHardLinkUndo"), std::vector({ "./CreateHardLinkUndo/__meta/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/prefix.path", /// /A "./CreateHardLinkUndo/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/f1", /// /A/f1 })); + + /// The undo has restored the implicit form. + EXPECT_FALSE(parsePrefixPath(readObject(object_storage, "./CreateHardLinkUndo/__meta/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/prefix.path")).has_explicit_file_list); } TEST_F(MetadataPlainRewritableDiskTest, CreateHardLinkRootFiles) @@ -1404,9 +1428,13 @@ TEST_F(MetadataPlainRewritableDiskTest, CreateHardLinkRootFiles) EXPECT_TRUE(metadata->existsFile("/f1")); EXPECT_TRUE(metadata->existsFile("/f2")); EXPECT_FALSE(metadata->existsFile("/f3")); + EXPECT_EQ(metadata->getHardlinkCount("/f1"), 1); + EXPECT_EQ(metadata->getStorageObjects("/f1").front().remote_path, metadata->getStorageObjects("/f2").front().remote_path); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("/f2").front().remote_path), "f1"); + /// The root directory has switched to the explicit file list, stored under the reserved remote path. EXPECT_EQ(listAllBlobs("CreateHardLinkRootFiles"), std::vector({ + "./CreateHardLinkRootFiles/__meta/__root/prefix.path", "./CreateHardLinkRootFiles/__root/f1", - "./CreateHardLinkRootFiles/__root/f2", })); } @@ -1788,7 +1816,6 @@ TEST_F(MetadataPlainRewritableDiskTest, UncommittedHardlink) "./UncommittedHardlink/__meta/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/prefix.path", "./UncommittedHardlink/__meta/ykwvvchguqasvfnkikaqtiebknfzafwv/prefix.path", "./UncommittedHardlink/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/file", - "./UncommittedHardlink/ykwvvchguqasvfnkikaqtiebknfzafwv/file" })); metadata = restartMetadataStorage("UncommittedHardlink"); @@ -1798,6 +1825,7 @@ TEST_F(MetadataPlainRewritableDiskTest, UncommittedHardlink) EXPECT_TRUE(metadata->existsFile("/X/Y/Z/file")); EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("/A/B/C/file").front().remote_path), "finally!"); EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("/X/Y/Z/file").front().remote_path), "finally!"); + EXPECT_EQ(metadata->getHardlinkCount("/X/Y/Z/file"), 1); } TEST_F(MetadataPlainRewritableDiskTest, UncommittedHardlinkUndo) @@ -2234,3 +2262,795 @@ TEST_F(MetadataPlainRewritableDiskTest, ConcurrentCreateDirectory) EXPECT_TRUE(metadata->existsDirectory("A")); EXPECT_EQ(generateObjectKeyPrefixForDirectoryPath(metadata, "A/"), remote_prefix); } + +TEST_F(MetadataPlainRewritableDiskTest, HardLinkAcrossDirectories) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("HardLinkAcrossDirectories"); + auto object_storage = getObjectStorage("HardLinkAcrossDirectories"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "shared"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + size_t size_own = writeObject(object_storage, tx->generateObjectKeyForPath("B/own").serialize(), "own"); + tx->createMetadataFile("B/own", {StoredObject("own", "own", size_own)}); + tx->commit(DB::NoCommitOptions{}); + } + + { + auto tx = metadata->createTransaction(); + tx->createHardLink("A/f1", "B/f1"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto shared_remote_path = metadata->getStorageObjects("A/f1").front().remote_path; + EXPECT_EQ(shared_remote_path, metadata->getStorageObjects("B/f1").front().remote_path); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 1); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 1); + EXPECT_EQ(metadata->getHardlinkCount("B/own"), 0); + + /// The source directory keeps the implicit form, the target directory lists its files explicitly. + EXPECT_FALSE(parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "A"))).has_explicit_file_list); + const auto b_prefix_path = parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "B"))); + EXPECT_TRUE(b_prefix_path.has_explicit_file_list); + ASSERT_EQ(b_prefix_path.files.size(), 2u); + EXPECT_EQ(b_prefix_path.files[0].name, "f1"); + EXPECT_EQ(b_prefix_path.files[0].blob_key, generateObjectKeyPrefixForDirectoryPath(metadata, "A") + "/f1"); + EXPECT_EQ(b_prefix_path.files[0].bytes_size, 6u); + EXPECT_EQ(b_prefix_path.files[1].name, "own"); + EXPECT_EQ(b_prefix_path.files[1].blob_key, generateObjectKeyPrefixForDirectoryPath(metadata, "B") + "/own"); + EXPECT_EQ(b_prefix_path.files[1].bytes_size, 3u); + + metadata = restartMetadataStorage("HardLinkAcrossDirectories"); + EXPECT_EQ(sorted(metadata->listDirectory("A")), std::vector({"f1"})); + EXPECT_EQ(sorted(metadata->listDirectory("B")), std::vector({"f1", "own"})); + EXPECT_EQ(metadata->getStorageObjects("B/f1").front().remote_path, shared_remote_path); + EXPECT_EQ(metadata->getFileSize("B/f1"), 6u); + EXPECT_EQ(metadata->getFileSize("B/own"), 3u); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 1); + EXPECT_EQ(metadata->getHardlinkCount("B/own"), 0); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "shared"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/own").front().remote_path), "own"); +} + +TEST_F(MetadataPlainRewritableDiskTest, UnlinkKeepsSharedBlob) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("UnlinkKeepsSharedBlob"); + auto object_storage = getObjectStorage("UnlinkKeepsSharedBlob"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "shared"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->createHardLink("A/f1", "B/f1"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto shared_remote_path = metadata->getStorageObjects("A/f1").front().remote_path; + + /// Removing the original file keeps the blob for the link and switches the directory of the original to the explicit form. + { + auto tx = metadata->createTransaction(); + tx->unlinkFile("A/f1", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->commit(DB::NoCommitOptions{}); + EXPECT_TRUE(tx->getSubmittedForRemovalBlobs().empty()); + } + + EXPECT_FALSE(metadata->existsFile("A/f1")); + EXPECT_TRUE(metadata->existsFile("B/f1")); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 0); + EXPECT_EQ(readObject(object_storage, shared_remote_path), "shared"); + + const auto a_prefix_path = parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "A"))); + EXPECT_TRUE(a_prefix_path.has_explicit_file_list); + EXPECT_TRUE(a_prefix_path.files.empty()); + + metadata = restartMetadataStorage("UnlinkKeepsSharedBlob"); + EXPECT_FALSE(metadata->existsFile("A/f1")); + EXPECT_TRUE(metadata->existsDirectory("A")); + EXPECT_TRUE(metadata->existsFile("B/f1")); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "shared"); + + /// Removing the last link removes the blob. + { + auto tx = metadata->createTransaction(); + tx->unlinkFile("B/f1", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->commit(DB::NoCommitOptions{}); + ASSERT_EQ(tx->getSubmittedForRemovalBlobs().size(), 1u); + EXPECT_EQ(tx->getSubmittedForRemovalBlobs().front().remote_path, shared_remote_path); + } + + EXPECT_FALSE(metadata->existsFile("B/f1")); + EXPECT_FALSE(object_storage->exists(StoredObject(shared_remote_path))); + EXPECT_EQ(listAllBlobs("UnlinkKeepsSharedBlob"), std::vector({ + "./UnlinkKeepsSharedBlob/__meta/faefxnlkbtfqgxcbfqfjtztsocaqrnqn/prefix.path", + "./UnlinkKeepsSharedBlob/__meta/ykwvvchguqasvfnkikaqtiebknfzafwv/prefix.path", + })); +} + +TEST_F(MetadataPlainRewritableDiskTest, ExplicitDirectoryUsesRandomBlobNames) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("ExplicitDirectoryUsesRandomBlobNames"); + auto object_storage = getObjectStorage("ExplicitDirectoryUsesRandomBlobNames"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "v1"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->createHardLink("A/f1", "B/f1"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto v1_remote_path = metadata->getStorageObjects("A/f1").front().remote_path; + + { + auto tx = metadata->createTransaction(); + tx->unlinkFile("A/f1", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->commit(DB::NoCommitOptions{}); + } + + /// A new file with the same name must not clobber the blob that is still linked from B. + { + auto tx = metadata->createTransaction(); + const auto new_key = tx->generateObjectKeyForPath("A/f1").serialize(); + EXPECT_NE(new_key, v1_remote_path); + EXPECT_FALSE(new_key.ends_with("/f1")); + size_t size = writeObject(object_storage, new_key, "v2"); + tx->createMetadataFile("A/f1", {StoredObject(new_key, "A/f1", size)}); + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "v2"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "v1"); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 0); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 0); + + /// Rewriting a file in a directory with the explicit file list replaces its blob. + { + auto tx = metadata->createTransaction(); + const auto new_key = tx->generateObjectKeyForPath("A/f1").serialize(); + size_t size = writeObject(object_storage, new_key, "v3!"); + tx->createMetadataFile("A/f1", {StoredObject(new_key, "A/f1", size)}); + tx->commit(DB::NoCommitOptions{}); + ASSERT_EQ(tx->getSubmittedForRemovalBlobs().size(), 1u); + } + + EXPECT_EQ(metadata->getFileSize("A/f1"), 3u); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "v3!"); + EXPECT_EQ(listAllBlobs("ExplicitDirectoryUsesRandomBlobNames").size(), 4u); /// two prefix.path, v1 and v3 + + metadata = restartMetadataStorage("ExplicitDirectoryUsesRandomBlobNames"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "v3!"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "v1"); +} + +TEST_F(MetadataPlainRewritableDiskTest, RewriteSharedFileDoesNotAffectLink) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("RewriteSharedFileDoesNotAffectLink"); + auto object_storage = getObjectStorage("RewriteSharedFileDoesNotAffectLink"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "v1"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->createHardLink("A/f1", "B/f1"); + tx->commit(DB::NoCommitOptions{}); + } + + /// The original lives in a directory with the implicit file list, but its blob is shared, so the rewrite goes to a new blob. + { + auto tx = metadata->createTransaction(); + const auto new_key = tx->generateObjectKeyForPath("A/f1").serialize(); + EXPECT_NE(new_key, metadata->getStorageObjects("A/f1").front().remote_path); + size_t size = writeObject(object_storage, new_key, "v2 is longer"); + tx->createMetadataFile("A/f1", {StoredObject(new_key, "A/f1", size)}); + tx->commit(DB::NoCommitOptions{}); + EXPECT_TRUE(tx->getSubmittedForRemovalBlobs().empty()); + } + + EXPECT_EQ(metadata->getFileSize("A/f1"), 12u); + EXPECT_EQ(metadata->getFileSize("B/f1"), 2u); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "v2 is longer"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "v1"); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 0); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 0); + + metadata = restartMetadataStorage("RewriteSharedFileDoesNotAffectLink"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "v2 is longer"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "v1"); +} + +TEST_F(MetadataPlainRewritableDiskTest, MoveFileInExplicitDirectory) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("MoveFileInExplicitDirectory"); + auto object_storage = getObjectStorage("MoveFileInExplicitDirectory"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "shared"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->createHardLink("A/f1", "B/f1"); + size_t size_tmp = writeObject(object_storage, tx->generateObjectKeyForPath("B/tmp").serialize(), "tmp"); + tx->createMetadataFile("B/tmp", {StoredObject("tmp", "tmp", size_tmp)}); + tx->commit(DB::NoCommitOptions{}); + } + + const auto shared_remote_path = metadata->getStorageObjects("B/f1").front().remote_path; + const auto tmp_remote_path = metadata->getStorageObjects("B/tmp").front().remote_path; + + /// A rename inside a directory with the explicit file list does not touch the blobs. + { + auto tx = metadata->createTransaction(); + tx->moveFile("B/tmp", "B/renamed"); + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_FALSE(metadata->existsFile("B/tmp")); + EXPECT_EQ(metadata->getStorageObjects("B/renamed").front().remote_path, tmp_remote_path); + + /// Replacing the link removes the file, but the shared blob stays for the original. + { + auto tx = metadata->createTransaction(); + tx->replaceFile("B/renamed", "B/f1"); + tx->commit(DB::NoCommitOptions{}); + EXPECT_TRUE(tx->getSubmittedForRemovalBlobs().empty()); + } + + EXPECT_FALSE(metadata->existsFile("B/renamed")); + EXPECT_EQ(metadata->getStorageObjects("B/f1").front().remote_path, tmp_remote_path); + EXPECT_EQ(metadata->getStorageObjects("A/f1").front().remote_path, shared_remote_path); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 0); + EXPECT_EQ(readObject(object_storage, shared_remote_path), "shared"); + + /// Moving a shared file out of a directory with the implicit file list switches both directories to the explicit form. + { + auto tx = metadata->createTransaction(); + tx->createHardLink("A/f1", "B/link"); + tx->commit(DB::NoCommitOptions{}); + } + { + auto tx = metadata->createTransaction(); + tx->createDirectory("C"); + tx->moveFile("A/f1", "C/f1"); + tx->commit(DB::NoCommitOptions{}); + EXPECT_TRUE(tx->getSubmittedForRemovalBlobs().empty()); + } + + EXPECT_FALSE(metadata->existsFile("A/f1")); + EXPECT_EQ(metadata->getStorageObjects("C/f1").front().remote_path, shared_remote_path); + EXPECT_EQ(metadata->getStorageObjects("B/link").front().remote_path, shared_remote_path); + EXPECT_EQ(metadata->getHardlinkCount("C/f1"), 1); + EXPECT_TRUE(parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "A"))).has_explicit_file_list); + EXPECT_TRUE(parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "C"))).has_explicit_file_list); + + metadata = restartMetadataStorage("MoveFileInExplicitDirectory"); + EXPECT_FALSE(metadata->existsFile("A/f1")); + EXPECT_EQ(sorted(metadata->listDirectory("B")), std::vector({"f1", "link"})); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "tmp"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/link").front().remote_path), "shared"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("C/f1").front().remote_path), "shared"); +} + +TEST_F(MetadataPlainRewritableDiskTest, RemoveRecursiveKeepsSharedBlobs) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("RemoveRecursiveKeepsSharedBlobs"); + auto object_storage = getObjectStorage("RemoveRecursiveKeepsSharedBlobs"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectoryRecursive("A/sub"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/sub/f1").serialize(), "shared"); + tx->createMetadataFile("A/sub/f1", {StoredObject("f1", "f1", size)}); + size_t size_own = writeObject(object_storage, tx->generateObjectKeyForPath("A/own").serialize(), "own"); + tx->createMetadataFile("A/own", {StoredObject("own", "own", size_own)}); + tx->createHardLink("A/sub/f1", "B/f1"); + /// Two links inside the removed subtree. + tx->createHardLink("A/sub/f1", "A/f1_link"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto shared_remote_path = metadata->getStorageObjects("B/f1").front().remote_path; + const auto own_remote_path = metadata->getStorageObjects("A/own").front().remote_path; + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 2); + + { + auto tx = metadata->createTransaction(); + tx->removeRecursive("A", /*should_remove_objects=*/nullptr); + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_FALSE(metadata->existsDirectory("A")); + EXPECT_TRUE(metadata->existsFile("B/f1")); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 0); + EXPECT_TRUE(object_storage->exists(StoredObject(shared_remote_path))); + EXPECT_FALSE(object_storage->exists(StoredObject(own_remote_path))); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "shared"); + + metadata = restartMetadataStorage("RemoveRecursiveKeepsSharedBlobs"); + EXPECT_FALSE(metadata->existsDirectory("A")); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "shared"); + + { + auto tx = metadata->createTransaction(); + tx->removeRecursive("B", /*should_remove_objects=*/nullptr); + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_FALSE(object_storage->exists(StoredObject(shared_remote_path))); + EXPECT_EQ(listAllBlobs("RemoveRecursiveKeepsSharedBlobs"), std::vector()); +} + +TEST_F(MetadataPlainRewritableDiskTest, HardLinkThenWriteInSameTransaction) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("HardLinkThenWriteInSameTransaction"); + auto object_storage = getObjectStorage("HardLinkThenWriteInSameTransaction"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "shared"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->commit(DB::NoCommitOptions{}); + } + + /// This is how a part is cloned: a new directory, hard links, a few files written and removed, all in one transaction. + { + auto tx = metadata->createTransaction(); + tx->createDirectory("B"); + tx->createHardLink("A/f1", "B/f1"); + tx->createHardLink("A/f1", "B/to_remove"); + const auto new_key = tx->generateObjectKeyForPath("B/new").serialize(); + EXPECT_FALSE(new_key.ends_with("/new")); + size_t size = writeObject(object_storage, new_key, "new"); + tx->createMetadataFile("B/new", {StoredObject(new_key, "B/new", size)}); + tx->unlinkFile("B/to_remove", /*if_exists=*/true, /*should_remove_objects=*/true); + tx->unlinkFile("B/absent", /*if_exists=*/true, /*should_remove_objects=*/true); + tx->commit(DB::NoCommitOptions{}); + EXPECT_TRUE(tx->getSubmittedForRemovalBlobs().empty()); + } + + EXPECT_EQ(sorted(metadata->listDirectory("B")), std::vector({"f1", "new"})); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 1); + EXPECT_EQ(metadata->getStorageObjects("A/f1").front().remote_path, metadata->getStorageObjects("B/f1").front().remote_path); + + metadata = restartMetadataStorage("HardLinkThenWriteInSameTransaction"); + EXPECT_EQ(sorted(metadata->listDirectory("B")), std::vector({"f1", "new"})); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "shared"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/new").front().remote_path), "new"); + EXPECT_EQ(listAllBlobs("HardLinkThenWriteInSameTransaction").size(), 4u); /// two prefix.path, f1 and new +} + +TEST_F(MetadataPlainRewritableDiskTest, CreateHardLinkAndRewriteInSameTransaction) +{ + thread_local_rng.seed(42); + + const std::string test = "CreateHardLinkAndRewriteInSameTransaction"; + auto metadata = getMetadataStorage(test); + auto object_storage = getObjectStorage(test); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + tx->commit(DB::NoCommitOptions{}); + } + + /// The file is created, hard-linked and rewritten by the same transaction. The rewrite has to go to a new blob, + /// otherwise it would clobber the contents that the link is supposed to keep. + { + auto tx = metadata->createTransaction(); + + const auto first_key = tx->generateObjectKeyForPath("A/f1").serialize(); + size_t first_size = writeObject(object_storage, first_key, "old"); + tx->createMetadataFile("A/f1", {StoredObject(first_key, "A/f1", first_size)}); + + tx->createHardLink("A/f1", "B/f1"); + + const auto second_key = tx->generateObjectKeyForPath("A/f1").serialize(); + EXPECT_NE(second_key, first_key); + size_t second_size = writeObject(object_storage, second_key, "new!"); + tx->createMetadataFile("A/f1", {StoredObject(second_key, "A/f1", second_size)}); + + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "new!"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "old"); + EXPECT_EQ(metadata->getFileSize("A/f1"), 4u); + EXPECT_EQ(metadata->getFileSize("B/f1"), 3u); + /// The two files do not share a blob anymore. + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 0); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 0); + + metadata = restartMetadataStorage(test); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "new!"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "old"); + EXPECT_EQ(metadata->getFileSize("A/f1"), 4u); + EXPECT_EQ(metadata->getFileSize("B/f1"), 3u); + EXPECT_EQ(listAllBlobs(test).size(), 4u); /// two prefix.path and two blobs +} + +TEST_F(MetadataPlainRewritableDiskTest, CreateHardLinkAndRewriteTwiceInSameTransaction) +{ + thread_local_rng.seed(42); + + const std::string test = "CreateHardLinkAndRewriteTwiceInSameTransaction"; + auto metadata = getMetadataStorage(test); + auto object_storage = getObjectStorage(test); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "old"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->commit(DB::NoCommitOptions{}); + } + + const auto shared_key = metadata->getStorageObjects("A/f1").front().remote_path; + + /// The file is hard-linked and then rewritten twice by the same transaction. The first rewrite goes to a blob with + /// a random name, which switches `A` to the explicit file list, so the second rewrite must not fall back to the + /// default location of `A/f1` - that is the blob `B/f1` is keeping alive. + { + auto tx = metadata->createTransaction(); + tx->createHardLink("A/f1", "B/f1"); + + const auto first_key = tx->generateObjectKeyForPath("A/f1").serialize(); + EXPECT_NE(first_key, shared_key); + size_t first_size = writeObject(object_storage, first_key, "first"); + tx->createMetadataFile("A/f1", {StoredObject(first_key, "A/f1", first_size)}); + + const auto second_key = tx->generateObjectKeyForPath("A/f1").serialize(); + EXPECT_NE(second_key, shared_key); + EXPECT_NE(second_key, first_key); + size_t second_size = writeObject(object_storage, second_key, "second"); + tx->createMetadataFile("A/f1", {StoredObject(second_key, "A/f1", second_size)}); + + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_EQ(metadata->getStorageObjects("B/f1").front().remote_path, shared_key); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "second"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "old"); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 0); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 0); + + metadata = restartMetadataStorage(test); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "second"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "old"); + /// The blob of the first rewrite is gone: two `prefix.path` objects and the two blobs are left. + EXPECT_EQ(listAllBlobs(test).size(), 4u); +} + +TEST_F(MetadataPlainRewritableDiskTest, MoveFileThenHardLinkAndRewriteInSameTransaction) +{ + thread_local_rng.seed(42); + + const std::string test = "MoveFileThenHardLinkAndRewriteInSameTransaction"; + auto metadata = getMetadataStorage(test); + auto object_storage = getObjectStorage(test); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + tx->createDirectory("C"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "moved"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->commit(DB::NoCommitOptions{}); + } + + /// The file is moved into a directory with the implicit file list, so its blob is copied to the default location + /// there, then it is hard-linked from the new path and rewritten - all by one transaction. The transaction has to + /// see the moved file, otherwise the rewrite reuses the default location and clobbers what the link preserves. + { + auto tx = metadata->createTransaction(); + tx->moveFile("A/f1", "B/f1"); + tx->createHardLink("B/f1", "C/f1"); + + const auto new_key = tx->generateObjectKeyForPath("B/f1").serialize(); + size_t new_size = writeObject(object_storage, new_key, "rewritten"); + tx->createMetadataFile("B/f1", {StoredObject(new_key, "B/f1", new_size)}); + + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_FALSE(metadata->existsFile("A/f1")); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "rewritten"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("C/f1").front().remote_path), "moved"); + EXPECT_EQ(metadata->getHardlinkCount("B/f1"), 0); + EXPECT_EQ(metadata->getHardlinkCount("C/f1"), 0); + + metadata = restartMetadataStorage(test); + EXPECT_FALSE(metadata->existsFile("A/f1")); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "rewritten"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("C/f1").front().remote_path), "moved"); +} + +TEST_F(MetadataPlainRewritableDiskTest, ReplaceFileThenHardLinkAndRewriteInSameTransaction) +{ + thread_local_rng.seed(42); + + const std::string test = "ReplaceFileThenHardLinkAndRewriteInSameTransaction"; + auto metadata = getMetadataStorage(test); + auto object_storage = getObjectStorage(test); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + tx->createDirectory("C"); + size_t size_from = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "replacing"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size_from)}); + size_t size_other = writeObject(object_storage, tx->generateObjectKeyForPath("B/other").serialize(), "other"); + tx->createMetadataFile("B/other", {StoredObject("other", "other", size_other)}); + tx->commit(DB::NoCommitOptions{}); + } + + /// The same through `replaceFile`. The target must not exist yet: for a move that copies the blob the target gets + /// the default location of its own path, which is also the key a file already sitting there would have, so a + /// pre-existing target hides a stale transaction view instead of exposing it. + { + auto tx = metadata->createTransaction(); + tx->replaceFile("A/f1", "B/f1"); + tx->createHardLink("B/f1", "C/f1"); + + const auto new_key = tx->generateObjectKeyForPath("B/f1").serialize(); + size_t new_size = writeObject(object_storage, new_key, "rewritten"); + tx->createMetadataFile("B/f1", {StoredObject(new_key, "B/f1", new_size)}); + + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_FALSE(metadata->existsFile("A/f1")); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "rewritten"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("C/f1").front().remote_path), "replacing"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/other").front().remote_path), "other"); + + metadata = restartMetadataStorage(test); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "rewritten"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("C/f1").front().remote_path), "replacing"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/other").front().remote_path), "other"); +} + +TEST_F(MetadataPlainRewritableDiskTest, EmptyDirectoryMetadataIsNotLoadedAsRoot) +{ + thread_local_rng.seed(42); + + const std::string test = "EmptyDirectoryMetadataIsNotLoadedAsRoot"; + auto metadata = getMetadataStorage(test); + auto object_storage = getObjectStorage(test); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "data"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + size_t root_size = writeObject(object_storage, tx->generateObjectKeyForPath("root.txt").serialize(), "root"); + tx->createMetadataFile("root.txt", {StoredObject("root.txt", "root.txt", root_size)}); + tx->createDirectory("B"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto root_file_key = metadata->getStorageObjects("root.txt").front().remote_path; + + /// An interrupted write of `prefix.path` can leave the object empty and visible (`LocalObjectStorage` writes to the + /// final key directly). Such an object does not contain the logical path of a directory, and only the reserved + /// metadata object maps to the logical root, so it must be ignored: loading it as the root would hide the files of + /// the root and send the lookups under it to the prefix of this directory. + writeObject(object_storage, createMetadataObjectPath(metadata, "B"), ""); + + metadata = restartMetadataStorage(test); + EXPECT_FALSE(metadata->existsDirectory("B")); + EXPECT_EQ(sorted(metadata->listDirectory("")), std::vector({"A", "root.txt"})); + ASSERT_EQ(metadata->getStorageObjects("root.txt").front().remote_path, root_file_key); + EXPECT_EQ(readObject(object_storage, root_file_key), "root"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "data"); +} + +TEST_F(MetadataPlainRewritableDiskTest, HardLinksDisabled) +{ + thread_local_rng.seed(42); + + /// Without `enable_hard_links` the blob is copied, as before the hard links were implemented, + /// and the layout stays in the implicit form, which older servers can read. + hard_links_enabled = false; + + const std::string test = "HardLinksDisabled"; + auto metadata = getMetadataStorage(test); + auto object_storage = getObjectStorage(test); + + EXPECT_FALSE(metadata->supportsHardLinks()); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + const auto key = tx->generateObjectKeyForPath("A/f1").serialize(); + EXPECT_TRUE(key.ends_with("/f1")); + size_t size = writeObject(object_storage, key, "data"); + tx->createMetadataFile("A/f1", {StoredObject(key, "A/f1", size)}); + tx->commit(DB::NoCommitOptions{}); + } + + { + auto tx = metadata->createTransaction(); + tx->createHardLink("A/f1", "A/f2"); + tx->commit(DB::NoCommitOptions{}); + } + + /// A separate blob for every file. + EXPECT_NE(metadata->getStorageObjects("A/f1").front().remote_path, metadata->getStorageObjects("A/f2").front().remote_path); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f2").front().remote_path), "data"); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 0); + + const auto prefix_path_object = createMetadataObjectPath(metadata, "A"); + EXPECT_FALSE(parsePrefixPath(readObject(object_storage, prefix_path_object)).has_explicit_file_list); + + metadata = restartMetadataStorage(test); + EXPECT_EQ(sorted(metadata->listDirectory("A")), std::vector({"f1", "f2"})); + EXPECT_EQ(metadata->getFileSize("A/f2"), 4u); +} + +TEST_F(MetadataPlainRewritableDiskTest, UnlinkSharedFileUndo) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("UnlinkSharedFileUndo"); + auto object_storage = getObjectStorage("UnlinkSharedFileUndo"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "shared"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->createHardLink("A/f1", "B/f1"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto a_prefix_path_before = readObject(object_storage, createMetadataObjectPath(metadata, "A")); + const auto b_prefix_path_before = readObject(object_storage, createMetadataObjectPath(metadata, "B")); + + { + auto tx = metadata->createTransaction(); + tx->unlinkFile("A/f1", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->unlinkFile("B/f1", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->moveFile("non-existing", "other-place"); + EXPECT_ANY_THROW(tx->commit(DB::NoCommitOptions{})); + EXPECT_TRUE(tx->getSubmittedForRemovalBlobs().empty()); + } + + EXPECT_TRUE(metadata->existsFile("A/f1")); + EXPECT_TRUE(metadata->existsFile("B/f1")); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 1); + EXPECT_EQ(readObject(object_storage, createMetadataObjectPath(metadata, "A")), a_prefix_path_before); + EXPECT_EQ(readObject(object_storage, createMetadataObjectPath(metadata, "B")), b_prefix_path_before); + + metadata = restartMetadataStorage("UnlinkSharedFileUndo"); + EXPECT_TRUE(metadata->existsFile("A/f1")); + EXPECT_TRUE(metadata->existsFile("B/f1")); + EXPECT_EQ(metadata->getHardlinkCount("A/f1"), 1); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "shared"); +} + +TEST_F(MetadataPlainRewritableDiskTest, MoveDirectoryKeepsExplicitFileList) +{ + thread_local_rng.seed(42); + + auto metadata = getMetadataStorage("MoveDirectoryKeepsExplicitFileList"); + auto object_storage = getObjectStorage("MoveDirectoryKeepsExplicitFileList"); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectoryRecursive("B/sub"); + size_t size = writeObject(object_storage, tx->generateObjectKeyForPath("A/f1").serialize(), "shared"); + tx->createMetadataFile("A/f1", {StoredObject("f1", "f1", size)}); + tx->createHardLink("A/f1", "B/sub/f1"); + tx->commit(DB::NoCommitOptions{}); + } + + { + auto tx = metadata->createTransaction(); + tx->moveDirectory("B", "MOVED"); + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_FALSE(metadata->existsDirectory("B")); + EXPECT_TRUE(metadata->existsFile("MOVED/sub/f1")); + EXPECT_EQ(metadata->getHardlinkCount("MOVED/sub/f1"), 1); + + const auto prefix_path = parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "MOVED/sub"))); + EXPECT_TRUE(prefix_path.has_explicit_file_list); + EXPECT_EQ(prefix_path.logical_path, "MOVED/sub/"); + ASSERT_EQ(prefix_path.files.size(), 1u); + EXPECT_EQ(prefix_path.files[0].blob_key, generateObjectKeyPrefixForDirectoryPath(metadata, "A") + "/f1"); + + metadata = restartMetadataStorage("MoveDirectoryKeepsExplicitFileList"); + EXPECT_TRUE(metadata->existsFile("MOVED/sub/f1")); + EXPECT_EQ(metadata->getStorageObjects("MOVED/sub/f1").front().remote_path, metadata->getStorageObjects("A/f1").front().remote_path); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("MOVED/sub/f1").front().remote_path), "shared"); +} + +TEST(PlainRewritablePrefixPath, ImplicitForm) +{ + DirectoryRemoteInfo directory{.remote_path = "aaealinyzgdzycgcnpgaapdssrjirnnr", .etag = "", .files = {{"f1", FileRemoteInfo{.bytes_size = 1, .last_modified = 0, .blob_key = {}}}}}; + EXPECT_EQ(serializePrefixPath("hello/world/", directory), "hello/world/"); + + const auto parsed = parsePrefixPath("hello/world/"); + EXPECT_EQ(parsed.logical_path, "hello/world/"); + EXPECT_FALSE(parsed.has_explicit_file_list); + EXPECT_TRUE(parsed.files.empty()); +} + +TEST(PlainRewritablePrefixPath, ExplicitForm) +{ + DirectoryRemoteInfo directory{ + .remote_path = "aaealinyzgdzycgcnpgaapdssrjirnnr", + .etag = "", + .files = { + {"upyachka.bin", FileRemoteInfo{.bytes_size = 567, .last_modified = 0, .blob_key = {}}}, + {"hello.json", FileRemoteInfo{.bytes_size = 1234, .last_modified = 0, .blob_key = "gfkoqxvyhaasroiodbeurnftnwieiihy/hello.json"}}, + {"with\ttab\nand newline", FileRemoteInfo{.bytes_size = 0, .last_modified = 0, .blob_key = "gfkoqxvyhaasroiodbeurnftnwieiihy/w\tf"}}, + }, + .has_explicit_file_list = true, + }; + + const auto serialized = serializePrefixPath("hello/world/", directory); + EXPECT_EQ( + serialized, + "hello/world/\n" + "files: 3\n" + "hello.json\tgfkoqxvyhaasroiodbeurnftnwieiihy/hello.json\t1234\n" + "upyachka.bin\taaealinyzgdzycgcnpgaapdssrjirnnr/upyachka.bin\t567\n" + "with\\ttab\\nand newline\tgfkoqxvyhaasroiodbeurnftnwieiihy/w\\tf\t0\n"); + + const auto parsed = parsePrefixPath(serialized); + EXPECT_EQ(parsed.logical_path, "hello/world/"); + EXPECT_TRUE(parsed.has_explicit_file_list); + ASSERT_EQ(parsed.files.size(), 3u); + EXPECT_EQ(parsed.files[0].name, "hello.json"); + EXPECT_EQ(parsed.files[0].blob_key, "gfkoqxvyhaasroiodbeurnftnwieiihy/hello.json"); + EXPECT_EQ(parsed.files[0].bytes_size, 1234u); + EXPECT_EQ(parsed.files[1].name, "upyachka.bin"); + EXPECT_EQ(parsed.files[1].blob_key, "aaealinyzgdzycgcnpgaapdssrjirnnr/upyachka.bin"); + EXPECT_EQ(parsed.files[1].bytes_size, 567u); + EXPECT_EQ(parsed.files[2].name, "with\ttab\nand newline"); + EXPECT_EQ(parsed.files[2].blob_key, "gfkoqxvyhaasroiodbeurnftnwieiihy/w\tf"); + + EXPECT_EQ(parsePrefixPath("A/\nfiles: 0\n").files.size(), 0u); + EXPECT_TRUE(parsePrefixPath("A/\nfiles: 0\n").has_explicit_file_list); + + EXPECT_THROW(parsePrefixPath("A/\nfiles:"), Exception); + EXPECT_THROW(parsePrefixPath("A/\nfiles: 1\n"), Exception); + EXPECT_THROW(parsePrefixPath("A/\nfiles: 1\nonly_name\n"), Exception); + EXPECT_THROW(parsePrefixPath("A/\nfiles: 1\na\tb\t1\nc\td\t2\n"), Exception); + EXPECT_THROW(parsePrefixPath("A/\nfiles: 1\na\tb\tx\n"), Exception); +} + diff --git a/src/Storages/StorageMergeTree.cpp b/src/Storages/StorageMergeTree.cpp index b0b538b6615c..900a10446aa9 100644 --- a/src/Storages/StorageMergeTree.cpp +++ b/src/Storages/StorageMergeTree.cpp @@ -122,6 +122,7 @@ namespace MergeTreeSetting extern const MergeTreeSettingsMergeTreePatchPartsVersion patch_parts_version; extern const MergeTreeSettingsBool always_use_copy_instead_of_hardlinks; extern const MergeTreeSettingsBool assign_part_uuids; + extern const MergeTreeSettingsBool table_disk; extern const MergeTreeSettingsDeduplicateMergeProjectionMode deduplicate_merge_projection_mode; extern const MergeTreeSettingsBool enable_replacing_merge_with_cleanup_for_min_age_to_force_merge; extern const MergeTreeSettingsUInt64 finished_mutations_to_keep; @@ -169,6 +170,9 @@ namespace ActionLocks extern const StorageActionBlockType Cleanup; } +/// The directory with the log of the block numbers inserted into a non-replicated table, see `MergeTreeDeduplicationLog`. +static constexpr auto DEDUPLICATION_LOGS_DIR_NAME = "deduplication_logs"; + static MergeTreeTransactionPtr tryGetTransactionForMutation(const MergeTreeMutationEntry & mutation, LoggerPtr log = nullptr) { chassert(!mutation.tid.isEmpty()); @@ -460,9 +464,49 @@ StorageMergeTree::write(const ASTPtr & /*query*/, const StorageMetadataPtr & met void StorageMergeTree::drop() { shutdown(true); + + /// With the `table_disk` setting the table directory is the root of the disk, which `dropAllData` cannot remove + /// recursively (see `MetadataStorageFromPlainObjectStorageRemoveRecursiveOperation`), so the state that this table + /// keeps there would survive the drop and get loaded by the next table created on the same disk. + if ((*getSettings())[MergeTreeSetting::table_disk]) + removeOwnFilesInDiskRootOnDrop(); + dropAllData(); } +void StorageMergeTree::removeOwnFilesInDiskRootOnDrop() +{ + for (const auto & disk : getDisks()) + { + if (disk->isBroken() || disk->isReadOnly()) + continue; + + size_t removed_count = 0; + for (auto it = disk->iterateDirectory(relative_data_path); it->isValid(); it->next()) + { + if (startsWith(it->name(), "mutation_") || startsWith(it->name(), "tmp_mutation_")) + { + LOG_DEBUG(log, "Removing mutation file {} on drop", it->path()); + disk->removeFile(it->path()); + ++removed_count; + } + } + + /// Otherwise the next table created on the same disk loads the block numbers of this one and deduplicates + /// (silently skips) its inserts. + const auto deduplication_logs_path = fs::path(relative_data_path) / DEDUPLICATION_LOGS_DIR_NAME; + if (disk->existsDirectory(deduplication_logs_path)) + { + LOG_DEBUG(log, "Removing the deduplication log {} on drop", deduplication_logs_path.string()); + disk->removeRecursive(deduplication_logs_path); + ++removed_count; + } + + if (removed_count > 0) + LOG_INFO(log, "Removed {} entries of this table from the root of the disk {} on drop", removed_count, disk->getName()); + } +} + void StorageMergeTree::alter( const AlterCommands & commands, ContextPtr local_context, @@ -1562,7 +1606,7 @@ void StorageMergeTree::loadDeduplicationLog() throw Exception(ErrorCodes::BAD_ARGUMENTS, "Deduplication for non-replicated MergeTree in old syntax is not supported"); auto disk = getDisks()[0]; - std::string path = fs::path(relative_data_path) / "deduplication_logs"; + std::string path = fs::path(relative_data_path) / DEDUPLICATION_LOGS_DIR_NAME; /// Deduplication log only matters on INSERTs. if (!disk->isReadOnly()) diff --git a/src/Storages/StorageMergeTree.h b/src/Storages/StorageMergeTree.h index 5ce87cdab5b7..9481231f9bde 100644 --- a/src/Storages/StorageMergeTree.h +++ b/src/Storages/StorageMergeTree.h @@ -188,6 +188,9 @@ private: const bool support_transaction; void loadMutations(); + /// Removes the state that this table keeps in the root of its disk, which `dropAllData` leaves alone + /// when the table occupies the whole disk (`table_disk`). + void removeOwnFilesInDiskRootOnDrop(); /// Load and initialize deduplication logs. Even if deduplication setting /// equals zero creates object with deduplication window equals zero. diff --git a/tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.reference b/tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.reference new file mode 100644 index 000000000000..00c126c4f4b6 --- /dev/null +++ b/tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.reference @@ -0,0 +1,15 @@ +200 19900 +-- update (mutation, unchanged columns are hard-linked) +200 100 +2 +-- add a materialized column, drop a column, rename a column +200 19900 39800 +-- a readonly reader on the same endpoint sees the mutated data +200 19900 39800 +0 +0 +-- merge after the mutations +200 19900 39800 +1 +-- dropping the tables removes all the objects +0 diff --git a/tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.sh b/tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.sh new file mode 100755 index 000000000000..c2a012745b62 --- /dev/null +++ b/tests/queries/0_stateless/05104_s3_plain_rewritable_hard_links.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-shared-merge-tree, no-replicated-database +# Tag no-fasttest: requires S3 +# Tag no-shared-merge-tree: does not support replication +# Tag no-replicated-database: plain rewritable should not be shared between replicas + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Mutations and ALTERs on a plain_rewritable disk with `enable_hard_links` hard-link the untouched files of a part +# into the mutated part, so the two parts share the blobs. A readonly reader on the same endpoint sees the mutated data. + +endpoint="http://localhost:11111/test/${CLICKHOUSE_TEST_UNIQUE_NAME}/" +disk_args="type = s3_plain_rewritable, endpoint = '${endpoint}', access_key_id = clickhouse, secret_access_key = clickhouse, enable_hard_links = 1" + +${CLICKHOUSE_CLIENT} -m --query " +DROP TABLE IF EXISTS writer SYNC; +DROP TABLE IF EXISTS reader SYNC; + +CREATE TABLE writer (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk(name = '${CLICKHOUSE_TEST_UNIQUE_NAME}_writer', ${disk_args}), + min_bytes_for_wide_part = 0, old_parts_lifetime = 600; + +INSERT INTO writer SELECT number, toString(number) FROM numbers(100); +INSERT INTO writer SELECT number, toString(number) FROM numbers(100, 100); +SELECT count(), sum(key) FROM writer; +" + +echo '-- update (mutation, unchanged columns are hard-linked)' +${CLICKHOUSE_CLIENT} -m --query " +ALTER TABLE writer UPDATE value = concat(value, '!') WHERE key % 2 = 0 SETTINGS mutations_sync = 1; +SELECT count(), countIf(endsWith(value, '!')) FROM writer; +SELECT count() FROM system.parts WHERE database = currentDatabase() AND table = 'writer' AND active; +" + +echo '-- add a materialized column, drop a column, rename a column' +${CLICKHOUSE_CLIENT} -m --query " +ALTER TABLE writer ADD COLUMN doubled UInt32; +ALTER TABLE writer UPDATE doubled = key * 2 WHERE 1 SETTINGS mutations_sync = 1; +ALTER TABLE writer DROP COLUMN value SETTINGS mutations_sync = 1; +ALTER TABLE writer RENAME COLUMN doubled TO renamed SETTINGS mutations_sync = 1; +SELECT count(), sum(key), sum(renamed) FROM writer; +" + +echo '-- a readonly reader on the same endpoint sees the mutated data' +${CLICKHOUSE_CLIENT} -m --query " +CREATE TABLE reader (key Int32, renamed UInt32) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk(readonly = true, name = '${CLICKHOUSE_TEST_UNIQUE_NAME}_reader', ${disk_args}); +SELECT count(), sum(key), sum(renamed) FROM reader; +SELECT count() FROM (SELECT * FROM writer EXCEPT SELECT * FROM reader); +SELECT count() FROM (SELECT * FROM reader EXCEPT SELECT * FROM writer); +" + +echo '-- merge after the mutations' +${CLICKHOUSE_CLIENT} -m --query " +OPTIMIZE TABLE writer FINAL; +SELECT count(), sum(key), sum(renamed) FROM writer; +SELECT count() FROM system.parts WHERE database = currentDatabase() AND table = 'writer' AND active; +" + +echo '-- dropping the tables removes all the objects' +${CLICKHOUSE_CLIENT} -m --query " +DROP TABLE reader SYNC; +DROP TABLE writer SYNC; +SELECT count() FROM s3('${endpoint}**', 'clickhouse', 'clickhouse', 'One'); +" diff --git a/tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.reference b/tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.reference new file mode 100644 index 000000000000..c2231c94a09d --- /dev/null +++ b/tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.reference @@ -0,0 +1,6 @@ +-- the repeated insert of the same block is deduplicated +3 +-- the drop leaves no objects behind +0 +-- a table created on the same disk afterwards does not inherit the deduplication log +3 diff --git a/tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.sh b/tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.sh new file mode 100755 index 000000000000..b81eee1d18e3 --- /dev/null +++ b/tests/queries/0_stateless/05137_table_disk_drop_deduplication_log.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-shared-merge-tree, no-replicated-database +# Tag no-fasttest: requires S3 +# Tag no-shared-merge-tree: does not support replication +# Tag no-replicated-database: plain rewritable should not be shared between replicas + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# With `table_disk` the table occupies the root of the disk, which cannot be removed recursively, so `DROP TABLE` has to +# remove the state that the table keeps there itself. Otherwise the next table created on the same disk loads the +# deduplication log of the previous one and skips its inserts as duplicates. + +endpoint="http://localhost:11111/test/${CLICKHOUSE_TEST_UNIQUE_NAME}/" +disk_args="type = s3_plain_rewritable, endpoint = '${endpoint}', access_key_id = clickhouse, secret_access_key = clickhouse" + +function create_table() +{ + ${CLICKHOUSE_CLIENT} --query " + CREATE TABLE dedup (key Int32) ENGINE = MergeTree ORDER BY key + SETTINGS table_disk = 1, disk = disk(name = '${CLICKHOUSE_TEST_UNIQUE_NAME}', ${disk_args}), + non_replicated_deduplication_window = 100; + " +} + +${CLICKHOUSE_CLIENT} --query "DROP TABLE IF EXISTS dedup SYNC" + +create_table +${CLICKHOUSE_CLIENT} --query "INSERT INTO dedup VALUES (1), (2), (3)" +${CLICKHOUSE_CLIENT} --query "INSERT INTO dedup VALUES (1), (2), (3)" + +echo '-- the repeated insert of the same block is deduplicated' +${CLICKHOUSE_CLIENT} --query "SELECT count() FROM dedup" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE dedup SYNC" + +echo '-- the drop leaves no objects behind' +${CLICKHOUSE_CLIENT} --query "SELECT count() FROM s3('${endpoint}**', 'clickhouse', 'clickhouse', 'One')" + +echo '-- a table created on the same disk afterwards does not inherit the deduplication log' +create_table +${CLICKHOUSE_CLIENT} --query "INSERT INTO dedup VALUES (1), (2), (3)" +${CLICKHOUSE_CLIENT} --query "SELECT count() FROM dedup" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE dedup SYNC" From 11d3f3dd6088dd5362ba90c9b6eb94257fed339b Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:23:41 +0000 Subject: [PATCH 070/272] Build `flatc` with the real toolchain in clang-tidy builds The arm_tidy build failed with `flatc: Permission denied` while generating the Arrow IPC metadata headers. In clang-tidy builds the dummy launchers replace the compiler, archiver and linker with a script that only touches the output file, so `flatc` was an empty, non-executable file, and the `add_custom_command` that runs it could not. `protoc` avoids this because the whole `google-protobuf-cmake` directory is added from the window in `contrib/CMakeLists.txt` where the real launchers are restored. Wrapping just the `_flatbuffers` and `flatc` targets inside `arrow-cmake` with `disable_dummy_launchers_if_needed` is not enough: `CMAKE_AR` and `CMAKE_EXE_LINKER_FLAGS` are read once per directory, when the directory is done, so the dummy archiver and the dummy `--ld-path` still apply. Move the FlatBuffers library and `flatc` into their own `contrib/flatbuffers-cmake` directory, added from that window next to `protoc`, and export the library as `ch_contrib::flatbuffers`. The Arrow IPC bindings are still generated in `arrow-cmake`, which only picks the `flatc` executable (native sub-build when cross-compiling). Verified with a clang-tidy configured build: `flatc` is now linked for real and the generated headers are byte-identical to the release build. CI report: https://github.com/ClickHouse/ClickHouse/actions/runs/34402179412/job/102642502151 PR: https://github.com/ClickHouse/ClickHouse/pull/119100 Co-Authored-By: Claude Fable 5.1 --- contrib/CMakeLists.txt | 5 +- contrib/arrow-cmake/CMakeLists.txt | 73 ++------------------- contrib/flatbuffers-cmake/CMakeLists.txt | 82 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 68 deletions(-) create mode 100644 contrib/flatbuffers-cmake/CMakeLists.txt diff --git a/contrib/CMakeLists.txt b/contrib/CMakeLists.txt index a651b5fcd527..aa909b4f2cc1 100644 --- a/contrib/CMakeLists.txt +++ b/contrib/CMakeLists.txt @@ -43,7 +43,7 @@ function(add_contrib cmake_folder) endfunction() # Use the original launchers for the projects that are needed for source code generation: -# protoc and llvm-tlbgen. +# protoc, flatc and llvm-tlbgen. disable_heavy_build_check_if_needed() disable_dummy_launchers_if_needed() @@ -58,6 +58,7 @@ add_contrib (abseil-cpp-cmake abseil-cpp) add_contrib (zlib-ng-cmake zlib-ng) add_contrib (google-protobuf-cmake google-protobuf) add_contrib (grpc-cmake grpc) +add_contrib (flatbuffers-cmake flatbuffers) # builds `flatc`, which generates the Arrow IPC bindings add_contrib (compiler-rt-cmake llvm-project) add_contrib (libllvmlibc-cmake llvm-project) add_contrib (llvm-project-cmake llvm-project) @@ -105,7 +106,7 @@ add_contrib (thrift-cmake thrift) add_contrib (StringZilla-cmake StringZilla) # parquet/arrow/orc add_contrib (xsimd-cmake xsimd) # required by arrow's SIMD codepaths -add_contrib (arrow-cmake arrow) # requires: snappy, thrift, double-conversion, xsimd +add_contrib (arrow-cmake arrow) # requires: snappy, thrift, double-conversion, xsimd, flatbuffers add_contrib (avro-cmake avro) # requires: snappy add_contrib (openldap-cmake openldap) add_contrib (msgpack-c-cmake msgpack-c) diff --git a/contrib/arrow-cmake/CMakeLists.txt b/contrib/arrow-cmake/CMakeLists.txt index 9801c3e1a693..fe048f9d451e 100644 --- a/contrib/arrow-cmake/CMakeLists.txt +++ b/contrib/arrow-cmake/CMakeLists.txt @@ -97,25 +97,6 @@ if (ENABLE_ORC) DEPENDS "${PROTO_DIR}/orc_proto.proto" ${PROTOBUF_EXECUTABLE}) endif() -# === flatbuffers -set(FLATBUFFERS_SRC_DIR "${ClickHouse_SOURCE_DIR}/contrib/flatbuffers") -set(FLATBUFFERS_BINARY_DIR "${ClickHouse_BINARY_DIR}/contrib/flatbuffers") -set(FLATBUFFERS_INCLUDE_DIR "${FLATBUFFERS_SRC_DIR}/include") - -set(FLATBUFFERS_SRCS - ${FLATBUFFERS_SRC_DIR}/src/idl_parser.cpp - ${FLATBUFFERS_SRC_DIR}/src/file_manager.cpp - ${FLATBUFFERS_SRC_DIR}/src/file_name_manager.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_text.cpp - ${FLATBUFFERS_SRC_DIR}/src/reflection.cpp - ${FLATBUFFERS_SRC_DIR}/src/util.cpp) - -add_library(_flatbuffers STATIC ${FLATBUFFERS_SRCS}) -# SYSTEM so the FlatBuffers headers don't trip ClickHouse's -Werror -Weverything (e.g. -Wdocumentation) -# in consumers that include them directly, such as the native Arrow IPC reader/writer. -target_include_directories(_flatbuffers SYSTEM PUBLIC ${FLATBUFFERS_INCLUDE_DIR}) -target_compile_definitions(_flatbuffers PRIVATE -DFLATBUFFERS_LOCALE_INDEPENDENT=0) - # === Arrow IPC metadata headers generated by `flatc` # # Arrow ships pre-generated FlatBuffers bindings for its IPC metadata schemas in @@ -130,55 +111,15 @@ target_compile_definitions(_flatbuffers PRIVATE -DFLATBUFFERS_LOCALE_INDEPENDENT # the FlatBuffers submodule and the Arrow schemas as the single source of truth, and mirrors what # is done for the ORC protobuf sources with `protoc` above. # +# `flatc` itself is built in `contrib/flatbuffers-cmake`, which sits in the window of +# `contrib/CMakeLists.txt` where the real compiler and linker are used even in clang-tidy builds. +# # The `flatc` flags come from Arrow's own `cpp/build-support/update-flatbuffers.sh`, minus # `--scoped-enums`: the bindings Arrow checked in are unscoped and its C++ code spells the # enumerators accordingly (`Type_Int` rather than `Type::Int`). -set(FLATC_SRCS - ${FLATBUFFERS_SRC_DIR}/src/annotated_binary_text_gen.cpp - ${FLATBUFFERS_SRC_DIR}/src/bfbs_gen_lua.cpp - ${FLATBUFFERS_SRC_DIR}/src/bfbs_gen_nim.cpp - ${FLATBUFFERS_SRC_DIR}/src/binary_annotator.cpp - ${FLATBUFFERS_SRC_DIR}/src/code_generators.cpp - ${FLATBUFFERS_SRC_DIR}/src/flatc.cpp - ${FLATBUFFERS_SRC_DIR}/src/flatc_main.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_binary.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_cpp.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_csharp.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_dart.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_fbs.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_go.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_grpc.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_java.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_json_schema.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_kotlin.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_kotlin_kmp.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_lobster.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_php.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_python.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_rust.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_swift.cpp - ${FLATBUFFERS_SRC_DIR}/src/idl_gen_ts.cpp - ${FLATBUFFERS_SRC_DIR}/include/codegen/python.cc - ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/cpp_generator.cc - ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/go_generator.cc - ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/java_generator.cc - ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/python_generator.cc - ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/swift_generator.cc - ${FLATBUFFERS_SRC_DIR}/grpc/src/compiler/ts_generator.cc) - -add_executable(flatc ${FLATC_SRCS}) -target_link_libraries(flatc PRIVATE _flatbuffers) -target_include_directories(flatc SYSTEM PRIVATE - ${FLATBUFFERS_INCLUDE_DIR} - ${FLATBUFFERS_SRC_DIR} - ${FLATBUFFERS_SRC_DIR}/grpc) -target_compile_definitions(flatc PRIVATE -DFLATBUFFERS_LOCALE_INDEPENDENT=0) - -add_native_target (flatc) if (NOT CMAKE_HOST_SYSTEM_NAME STREQUAL CMAKE_SYSTEM_NAME OR NOT CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL CMAKE_SYSTEM_PROCESSOR OR USE_MUSL) - set_target_properties (flatc PROPERTIES IMPORTED_LOCATION "${PROJECT_BINARY_DIR}/native/contrib/arrow-cmake/flatc") - set(FLATC_EXECUTABLE "${PROJECT_BINARY_DIR}/native/contrib/arrow-cmake/flatc") + set(FLATC_EXECUTABLE "${PROJECT_BINARY_DIR}/native/contrib/flatbuffers-cmake/flatc") else () set(FLATC_EXECUTABLE $) endif() @@ -186,7 +127,7 @@ endif() set(ARROW_FORMAT_DIR "${ClickHouse_SOURCE_DIR}/contrib/arrow/format") # The bindings live in a "generated" subdirectory of the include directory, because that is how # Arrow's sources spell them: #include "generated/Message_generated.h". -set(ARROW_IPC_GENERATED_INCLUDE_DIR "${FLATBUFFERS_BINARY_DIR}") +set(ARROW_IPC_GENERATED_INCLUDE_DIR "${ClickHouse_BINARY_DIR}/contrib/flatbuffers") set(ARROW_IPC_GENERATED_DIR "${ARROW_IPC_GENERATED_INCLUDE_DIR}/generated") set(ARROW_FBS @@ -230,7 +171,7 @@ add_custom_target(_arrow_ipc_generated DEPENDS ${ARROW_IPC_GENERATED_HEADERS}) # the generated directory comes first so it shadows the pre-generated copies shipped by Arrow. add_library(_arrow_ipc_metadata INTERFACE) target_include_directories(_arrow_ipc_metadata SYSTEM INTERFACE "${ARROW_IPC_GENERATED_INCLUDE_DIR}" "${ARROW_SRC_DIR}") -target_link_libraries(_arrow_ipc_metadata INTERFACE _flatbuffers) +target_link_libraries(_arrow_ipc_metadata INTERFACE ch_contrib::flatbuffers) add_dependencies(_arrow_ipc_metadata _arrow_ipc_generated) add_library(ch_contrib::arrow_ipc_metadata ALIAS _arrow_ipc_metadata) @@ -670,7 +611,7 @@ add_library(ch_contrib::arrow ALIAS _arrow) target_link_libraries(_arrow PRIVATE boost::filesystem - _flatbuffers + ch_contrib::flatbuffers ch_contrib::double_conversion diff --git a/contrib/flatbuffers-cmake/CMakeLists.txt b/contrib/flatbuffers-cmake/CMakeLists.txt new file mode 100644 index 000000000000..2c95ea9ca659 --- /dev/null +++ b/contrib/flatbuffers-cmake/CMakeLists.txt @@ -0,0 +1,82 @@ +# FlatBuffers: the runtime library, used by the native Arrow IPC reader/writer and by Arrow itself, +# and the `flatc` schema compiler, which generates the Arrow IPC metadata bindings at build time +# (see `contrib/arrow-cmake`). +# +# Because `flatc` runs during the build, this directory is added from the window in +# `contrib/CMakeLists.txt` where the real compiler and linker are used even in clang-tidy builds, +# next to `protoc`. Elsewhere the dummy launchers would leave `flatc` as an empty file. The +# launchers are switched per directory (`CMAKE_AR` and `CMAKE_EXE_LINKER_FLAGS` are read when a +# directory is done), so the whole directory has to sit inside that window; wrapping just the +# targets does not work. + +set(LIBRARY_DIR "${ClickHouse_SOURCE_DIR}/contrib/flatbuffers") + +set(FLATBUFFERS_SRCS + ${LIBRARY_DIR}/src/idl_parser.cpp + ${LIBRARY_DIR}/src/file_manager.cpp + ${LIBRARY_DIR}/src/file_name_manager.cpp + ${LIBRARY_DIR}/src/idl_gen_text.cpp + ${LIBRARY_DIR}/src/reflection.cpp + ${LIBRARY_DIR}/src/util.cpp) + +add_library(_flatbuffers STATIC ${FLATBUFFERS_SRCS}) +# SYSTEM so the FlatBuffers headers don't trip ClickHouse's -Werror -Weverything (e.g. -Wdocumentation) +# in consumers that include them directly, such as the native Arrow IPC reader/writer. +target_include_directories(_flatbuffers SYSTEM PUBLIC "${LIBRARY_DIR}/include") +target_compile_definitions(_flatbuffers PRIVATE -DFLATBUFFERS_LOCALE_INDEPENDENT=0) +add_library(ch_contrib::flatbuffers ALIAS _flatbuffers) + +# === flatc +# +# Generated FlatBuffers code contains a `static_assert` on `FLATBUFFERS_VERSION_MAJOR`/`MINOR`/ +# `REVISION` that pins it to the exact FlatBuffers release it was produced with, so pre-generated +# bindings (such as the ones Arrow ships) cannot be compiled against any other version of the +# library. Build the compiler and regenerate the bindings from the schemas instead. + +set(FLATC_SRCS + ${LIBRARY_DIR}/src/annotated_binary_text_gen.cpp + ${LIBRARY_DIR}/src/bfbs_gen_lua.cpp + ${LIBRARY_DIR}/src/bfbs_gen_nim.cpp + ${LIBRARY_DIR}/src/binary_annotator.cpp + ${LIBRARY_DIR}/src/code_generators.cpp + ${LIBRARY_DIR}/src/flatc.cpp + ${LIBRARY_DIR}/src/flatc_main.cpp + ${LIBRARY_DIR}/src/idl_gen_binary.cpp + ${LIBRARY_DIR}/src/idl_gen_cpp.cpp + ${LIBRARY_DIR}/src/idl_gen_csharp.cpp + ${LIBRARY_DIR}/src/idl_gen_dart.cpp + ${LIBRARY_DIR}/src/idl_gen_fbs.cpp + ${LIBRARY_DIR}/src/idl_gen_go.cpp + ${LIBRARY_DIR}/src/idl_gen_grpc.cpp + ${LIBRARY_DIR}/src/idl_gen_java.cpp + ${LIBRARY_DIR}/src/idl_gen_json_schema.cpp + ${LIBRARY_DIR}/src/idl_gen_kotlin.cpp + ${LIBRARY_DIR}/src/idl_gen_kotlin_kmp.cpp + ${LIBRARY_DIR}/src/idl_gen_lobster.cpp + ${LIBRARY_DIR}/src/idl_gen_php.cpp + ${LIBRARY_DIR}/src/idl_gen_python.cpp + ${LIBRARY_DIR}/src/idl_gen_rust.cpp + ${LIBRARY_DIR}/src/idl_gen_swift.cpp + ${LIBRARY_DIR}/src/idl_gen_ts.cpp + ${LIBRARY_DIR}/include/codegen/python.cc + ${LIBRARY_DIR}/grpc/src/compiler/cpp_generator.cc + ${LIBRARY_DIR}/grpc/src/compiler/go_generator.cc + ${LIBRARY_DIR}/grpc/src/compiler/java_generator.cc + ${LIBRARY_DIR}/grpc/src/compiler/python_generator.cc + ${LIBRARY_DIR}/grpc/src/compiler/swift_generator.cc + ${LIBRARY_DIR}/grpc/src/compiler/ts_generator.cc) + +add_executable(flatc ${FLATC_SRCS}) +target_link_libraries(flatc PRIVATE _flatbuffers) +target_include_directories(flatc SYSTEM PRIVATE + "${LIBRARY_DIR}/include" + "${LIBRARY_DIR}" + "${LIBRARY_DIR}/grpc") +target_compile_definitions(flatc PRIVATE -DFLATBUFFERS_LOCALE_INDEPENDENT=0) + +# When cross-compiling, `flatc` has to run on the host, so it is built by the native sub-build +# (see `add_native_target` in the top-level `CMakeLists.txt`), same as `protoc`. +add_native_target (flatc) +if (NOT CMAKE_HOST_SYSTEM_NAME STREQUAL CMAKE_SYSTEM_NAME OR NOT CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL CMAKE_SYSTEM_PROCESSOR OR USE_MUSL) + set_target_properties (flatc PROPERTIES IMPORTED_LOCATION "${PROJECT_BINARY_DIR}/native/contrib/flatbuffers-cmake/flatc") +endif () From 5bedaa6af4ed85ecfdde2ab3347ca5ce3f0490ef Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:34:59 +0000 Subject: [PATCH 071/272] Fix loongarch64 build of `liburing` 2.15 Since 2.15, `liburing.h` includes `` for the new io_uring BPF filter API (`io_uring_register_bpf_filter`). The loongarch64 sysroot ships only a subset of the Linux UAPI headers and lacks `linux/filter.h` and `linux/bpf_common.h`, so the `Build (loongarch64)` job failed with: contrib/liburing/src/include/liburing.h:16:10: fatal error: 'linux/filter.h' file not found Provide verbatim copies of these two UAPI headers under `contrib/liburing-cmake/include-compat-loongarch64` and add that directory to the public include path of `_liburing` when `ARCH_LOONGARCH64` is set, alongside the generated `compat.h`. https://github.com/ClickHouse/ClickHouse/pull/118895 https://github.com/ClickHouse/ClickHouse/actions/runs/34279264337/job/102304005413 Co-Authored-By: Claude Fable 5.1 --- contrib/liburing-cmake/CMakeLists.txt | 8 ++ .../linux/bpf_common.h | 56 ++++++++++++ .../include-compat-loongarch64/linux/filter.h | 90 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h create mode 100644 contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h diff --git a/contrib/liburing-cmake/CMakeLists.txt b/contrib/liburing-cmake/CMakeLists.txt index f54ae29ff860..9f35c2385e63 100644 --- a/contrib/liburing-cmake/CMakeLists.txt +++ b/contrib/liburing-cmake/CMakeLists.txt @@ -28,6 +28,14 @@ add_compile_definitions (LIBURING_INTERNAL) set (LIBURING_COMPAT_INCLUDE_DIR "${ClickHouse_BINARY_DIR}/contrib/liburing/src/include-compat") set (LIBURING_COMPAT_HEADER "${LIBURING_COMPAT_INCLUDE_DIR}/liburing/compat.h") +# Since 2.15, `liburing.h` includes `` for the io_uring BPF filter API. +# The loongarch64 sysroot ships only a subset of the Linux UAPI headers and lacks +# `linux/filter.h` and `linux/bpf_common.h` (included by the former), so provide +# verbatim copies of them for that target. +if (ARCH_LOONGARCH64) + list (APPEND LIBURING_COMPAT_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include-compat-loongarch64") +endif () + set (LIBURING_CONFIG_HAS_KERNEL_RWF_T FALSE) set (LIBURING_CONFIG_HAS_KERNEL_TIMESPEC FALSE) set (LIBURING_CONFIG_HAS_OPEN_HOW FALSE) diff --git a/contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h b/contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h new file mode 100644 index 000000000000..9f0201beb6c4 --- /dev/null +++ b/contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h @@ -0,0 +1,56 @@ +/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */ +#ifndef __LINUX_BPF_COMMON_H__ +#define __LINUX_BPF_COMMON_H__ + +/* Instruction classes */ +#define BPF_CLASS(code) ((code) & 0x07) +#define BPF_LD 0x00 +#define BPF_LDX 0x01 +#define BPF_ST 0x02 +#define BPF_STX 0x03 +#define BPF_ALU 0x04 +#define BPF_JMP 0x05 +#define BPF_RET 0x06 +#define BPF_MISC 0x07 + +/* ld/ldx fields */ +#define BPF_SIZE(code) ((code) & 0x18) +#define BPF_W 0x00 +#define BPF_H 0x08 +#define BPF_B 0x10 +#define BPF_MODE(code) ((code) & 0xe0) +#define BPF_IMM 0x00 +#define BPF_ABS 0x20 +#define BPF_IND 0x40 +#define BPF_MEM 0x60 +#define BPF_LEN 0x80 +#define BPF_MSH 0xa0 + +/* alu/jmp fields */ +#define BPF_OP(code) ((code) & 0xf0) +#define BPF_ADD 0x00 +#define BPF_SUB 0x10 +#define BPF_MUL 0x20 +#define BPF_DIV 0x30 +#define BPF_OR 0x40 +#define BPF_AND 0x50 +#define BPF_LSH 0x60 +#define BPF_RSH 0x70 +#define BPF_NEG 0x80 +#define BPF_MOD 0x90 +#define BPF_XOR 0xa0 + +#define BPF_JA 0x00 +#define BPF_JEQ 0x10 +#define BPF_JGT 0x20 +#define BPF_JGE 0x30 +#define BPF_JSET 0x40 +#define BPF_SRC(code) ((code) & 0x08) +#define BPF_K 0x00 +#define BPF_X 0x08 + +#ifndef BPF_MAXINSNS +#define BPF_MAXINSNS 4096 +#endif + +#endif /* __LINUX_BPF_COMMON_H__ */ diff --git a/contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h b/contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h new file mode 100644 index 000000000000..eaef459e7bd4 --- /dev/null +++ b/contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h @@ -0,0 +1,90 @@ +/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */ +/* + * Linux Socket Filter Data Structures + */ + +#ifndef __LINUX_FILTER_H__ +#define __LINUX_FILTER_H__ + + +#include +#include + +/* + * Current version of the filter code architecture. + */ +#define BPF_MAJOR_VERSION 1 +#define BPF_MINOR_VERSION 1 + +/* + * Try and keep these values and structures similar to BSD, especially + * the BPF code definitions which need to match so you can share filters + */ + +struct sock_filter { /* Filter block */ + __u16 code; /* Actual filter code */ + __u8 jt; /* Jump true */ + __u8 jf; /* Jump false */ + __u32 k; /* Generic multiuse field */ +}; + +struct sock_fprog { /* Required for SO_ATTACH_FILTER. */ + unsigned short len; /* Number of filter blocks */ + struct sock_filter *filter; +}; + +/* ret - BPF_K and BPF_X also apply */ +#define BPF_RVAL(code) ((code) & 0x18) +#define BPF_A 0x10 + +/* misc */ +#define BPF_MISCOP(code) ((code) & 0xf8) +#define BPF_TAX 0x00 +#define BPF_TXA 0x80 + +/* + * Macros for filter block array initializers. + */ +#ifndef BPF_STMT +#define BPF_STMT(code, k) { (unsigned short)(code), 0, 0, k } +#endif +#ifndef BPF_JUMP +#define BPF_JUMP(code, k, jt, jf) { (unsigned short)(code), jt, jf, k } +#endif + +/* + * Number of scratch memory words for: BPF_ST and BPF_STX + */ +#define BPF_MEMWORDS 16 + +/* RATIONALE. Negative offsets are invalid in BPF. + We use them to reference ancillary data. + Unlike introduction new instructions, it does not break + existing compilers/optimizers. + */ +#define SKF_AD_OFF (-0x1000) +#define SKF_AD_PROTOCOL 0 +#define SKF_AD_PKTTYPE 4 +#define SKF_AD_IFINDEX 8 +#define SKF_AD_NLATTR 12 +#define SKF_AD_NLATTR_NEST 16 +#define SKF_AD_MARK 20 +#define SKF_AD_QUEUE 24 +#define SKF_AD_HATYPE 28 +#define SKF_AD_RXHASH 32 +#define SKF_AD_CPU 36 +#define SKF_AD_ALU_XOR_X 40 +#define SKF_AD_VLAN_TAG 44 +#define SKF_AD_VLAN_TAG_PRESENT 48 +#define SKF_AD_PAY_OFFSET 52 +#define SKF_AD_RANDOM 56 +#define SKF_AD_VLAN_TPID 60 +#define SKF_AD_MAX 64 + +#define SKF_NET_OFF (-0x100000) +#define SKF_LL_OFF (-0x200000) + +#define BPF_NET_OFF SKF_NET_OFF +#define BPF_LL_OFF SKF_LL_OFF + +#endif /* __LINUX_FILTER_H__ */ From 16fbb7d32a39facb743359284147fa60e25d979d Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Thu, 10 Sep 2026 22:49:45 +0000 Subject: [PATCH 072/272] Refactor `extractKeyValuePairs`: split token readers, unify vector code - Reading of keys and values is split into `readUnquotedKey`, `readQuotedKey`, `readUnquotedValue` and `readQuotedValue`, all returning `ReadResult` (`Ok`, `Discard`, `End`), so the main loop handles both kinds of tokens uniformly. `readQuotedValue` skips to the next pair delimiter itself. - The SSE and NEON search loops are replaced by one `findVectorized` written with GCC/Clang vector extensions. Only the byte permutation (`pshufb`/`tbl`) stays behind an `#if`; the match mask is extracted through two 64-bit halves instead of `pmovmskb` or the NEON narrowing trick. - The comment on the nibble tables is rewritten. Output is unchanged: the stateless tests and the differential test against the previous binary are byte-identical, and throughput is the same as before. Co-Authored-By: Claude Fable 5.1 --- src/Functions/extractKeyValuePairs.cpp | 353 ++++++++++++++++--------- 1 file changed, 221 insertions(+), 132 deletions(-) diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index 4064663b2188..a969a00d98cf 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -16,6 +16,7 @@ #include #include +#include #if defined(__SSSE3__) # include @@ -46,6 +47,25 @@ namespace constexpr char ESCAPE_CHARACTER = '\\'; +#if defined(__SSSE3__) || defined(__aarch64__) + +/// GCC/Clang vector extensions lower each lane operation to one SSE/NEON instruction; +/// only the byte permutation has no portable spelling. +using UInt8x16 = UInt8 __attribute__((vector_size(16))); +using UInt64x2 = UInt64 __attribute__((vector_size(16))); + +/// Returns a vector with `table[indexes[i]]` in lane `i`; every index must be less than 16. +inline UInt8x16 lookupBytes(UInt8x16 table, UInt8x16 indexes) +{ +#if defined(__SSSE3__) + return std::bit_cast(_mm_shuffle_epi8(std::bit_cast<__m128i>(table), std::bit_cast<__m128i>(indexes))); +#else + return std::bit_cast(vqtbl1q_u8(std::bit_cast(table), std::bit_cast(indexes))); +#endif +} + +#endif + /// A set of bytes with a vectorized search for the first byte that is (or is not) in the set. /// The lookup tables are built once per set, so a search has no setup cost. class ByteSet @@ -59,12 +79,22 @@ public: table[byte] = true; - /// The vectorized search classifies a byte by two nibble lookups: it is in the set iff - /// `low_nibble_table[low] & high_nibble_table[high]` is non-zero, where each distinct high - /// nibble present in the set owns one bit. That supports at most 8 distinct high nibbles, - /// which covers any set of ASCII punctuation; other sets use the scalar search only. + /// Tables for the vectorized search, which can only look up + /// 16-entry tables and therefore classifies a byte by its two nibbles: + /// + /// - Every high nibble that occurs in the set gets its own bit (`high_nibble_bit`). + /// - `high_nibble_table[high]` is that bit, or 0 if no member has this high nibble. + /// - `low_nibble_table[low]` is the union of the bits of all high nibbles that are + /// combined with this low nibble in some member. + /// - A byte is in the set iff the two lookups share a bit: + /// `low_nibble_table[low] & high_nibble_table[high] != 0`. + /// + /// The bits fit in one byte, so at most 8 distinct high nibbles are supported. That is + /// enough for any set of ASCII characters; a set that needs more is searched by the + /// scalar loop only. UInt8 high = byte >> 4; UInt8 low = byte & 0x0F; + if (!high_nibble_bit[high]) { if (num_high_nibbles == 8) @@ -81,7 +111,7 @@ public: high_nibble_table[high] = high_nibble_bit[high]; } - bool contains(char c) const + ALWAYS_INLINE bool contains(char c) const { return table[static_cast(c)]; } @@ -101,8 +131,10 @@ public: return pos; } +#if defined(__SSSE3__) || defined(__aarch64__) if (vectorized) pos = findVectorized(pos, end); +#endif for (; pos < end; ++pos) { @@ -117,54 +149,43 @@ private: static constexpr ptrdiff_t SCALAR_PREFIX = 16; static constexpr ptrdiff_t VECTOR_SIZE = 16; +#if defined(__SSSE3__) || defined(__aarch64__) /// Scans whole 16-byte blocks. Returns the position of the first byte matching the search, /// or the position from which fewer than 16 bytes remain. template const char * findVectorized(const char * pos, const char * end) const { -#if defined(__SSSE3__) - const __m128i low_table = _mm_loadu_si128(reinterpret_cast(low_nibble_table)); - const __m128i high_table = _mm_loadu_si128(reinterpret_cast(high_nibble_table)); - const __m128i low_mask = _mm_set1_epi8(0x0F); - const __m128i zero = _mm_setzero_si128(); + const auto low_table = std::bit_cast(low_nibble_table); + const auto high_table = std::bit_cast(high_nibble_table); for (; end - pos >= VECTOR_SIZE; pos += VECTOR_SIZE) { - __m128i bytes = _mm_loadu_si128(reinterpret_cast(pos)); - __m128i low = _mm_shuffle_epi8(low_table, _mm_and_si128(bytes, low_mask)); - __m128i high = _mm_shuffle_epi8(high_table, _mm_and_si128(_mm_srli_epi16(bytes, 4), low_mask)); - __m128i not_in_set = _mm_cmpeq_epi8(_mm_and_si128(low, high), zero); - auto mask = static_cast(_mm_movemask_epi8(not_in_set)); - if constexpr (positive) - mask = ~mask & 0xFFFFu; - if (mask) - return pos + std::countr_zero(mask); - } -#elif defined(__aarch64__) - const uint8x16_t low_table = vld1q_u8(low_nibble_table); - const uint8x16_t high_table = vld1q_u8(high_nibble_table); - const uint8x16_t low_mask = vdupq_n_u8(0x0F); + UInt8x16 bytes; + memcpy(&bytes, pos, VECTOR_SIZE); - for (; end - pos >= VECTOR_SIZE; pos += VECTOR_SIZE) - { - uint8x16_t bytes = vld1q_u8(reinterpret_cast(pos)); - uint8x16_t low = vqtbl1q_u8(low_table, vandq_u8(bytes, low_mask)); - uint8x16_t high = vqtbl1q_u8(high_table, vshrq_n_u8(bytes, 4)); - uint8x16_t not_in_set = vceqzq_u8(vandq_u8(low, high)); - /// Narrow each 0x00/0xFF byte of the comparison result to a nibble of a 64-bit mask. - UInt64 mask = vget_lane_u64(vreinterpret_u64_u8(vshrn_n_u16(vreinterpretq_u16_u8(not_in_set), 4)), 0); - if constexpr (positive) - mask = ~mask; - if (mask) - return pos + (std::countr_zero(mask) >> 2); + UInt8x16 low = lookupBytes(low_table, bytes & 0x0F); + UInt8x16 high = lookupBytes(high_table, bytes >> 4); + + /// 0xFF in the lanes of the bytes that match the search, 0x00 in the others. + auto match = (low & high) != UInt8x16{}; + if constexpr (!positive) + match = ~match; + + /// The first matching byte is the lowest non-zero byte, little-endian. + const auto halves = std::bit_cast(match); + if (halves[0]) + return pos + std::countr_zero(halves[0]) / 8; + if (halves[1]) + return pos + 8 + std::countr_zero(halves[1]) / 8; } -#endif + return pos; } +#endif bool table[256]{}; - alignas(16) UInt8 low_nibble_table[16]{}; - alignas(16) UInt8 high_nibble_table[16]{}; + UInt8 low_nibble_table[16]{}; + UInt8 high_nibble_table[16]{}; UInt8 high_nibble_bit[16]{}; size_t num_high_nibbles = 0; bool vectorized = true; @@ -192,6 +213,7 @@ public: buffer.clear(); in_buffer = true; } + buffer.append(chunk_begin, pos); ReadBufferFromMemory in(pos, end - pos); @@ -228,6 +250,17 @@ enum class Stop InvalidEscapeSequence, }; +/// Outcome of reading a key or a value. +enum class ReadResult +{ + /// The token was read: for a key `pos` is at the start of the value, for a value the pair is complete. + Ok, + /// The token is invalid, go back to waiting for a key. + Discard, + /// The input is over. + End, +}; + } struct KeyValuePairExtractor::Impl @@ -235,14 +268,15 @@ struct KeyValuePairExtractor::Impl Configuration configuration; /// Bytes skipped while waiting for a key: the delimiters, and the escape character with escaping. - ByteSet waiting_key_skip; - /// Bytes that end an unquoted key: both delimiters, the quoting character unless the strategy is - /// `ACCEPT`, and the escape character with escaping. - ByteSet key_stop; + ByteSet waiting_key_bytes; + /// Bytes that end an unquoted key: both delimiters, the quoting character + /// unless the strategy is `ACCEPT`, and the escape character with escaping. + ByteSet key_stop_bytes; /// Bytes that end an unquoted value: same as for a key, except the key-value delimiter is a regular byte. - ByteSet value_stop; + ByteSet value_stop_bytes; /// Bytes that end a quoted key or value: the quoting character, and the escape character with escaping. - ByteSet quoted_stop; + ByteSet quoted_stop_bytes; + /// Bytes that end a pair: the pair delimiters. ByteSet pair_delimiters; explicit Impl(const Configuration & configuration_) @@ -250,31 +284,31 @@ struct KeyValuePairExtractor::Impl { validate(); - waiting_key_skip.add(configuration.key_value_delimiter); - key_stop.add(configuration.key_value_delimiter); + waiting_key_bytes.add(configuration.key_value_delimiter); + key_stop_bytes.add(configuration.key_value_delimiter); for (char c : configuration.pair_delimiters) { - waiting_key_skip.add(c); - key_stop.add(c); - value_stop.add(c); + waiting_key_bytes.add(c); + key_stop_bytes.add(c); + value_stop_bytes.add(c); pair_delimiters.add(c); } if (configuration.unexpected_quoting_character_strategy != UnexpectedQuotingCharacterStrategy::ACCEPT) { - key_stop.add(configuration.quoting_character); - value_stop.add(configuration.quoting_character); + key_stop_bytes.add(configuration.quoting_character); + value_stop_bytes.add(configuration.quoting_character); } - quoted_stop.add(configuration.quoting_character); + quoted_stop_bytes.add(configuration.quoting_character); if (configuration.with_escaping) { - waiting_key_skip.add(ESCAPE_CHARACTER); - key_stop.add(ESCAPE_CHARACTER); - value_stop.add(ESCAPE_CHARACTER); - quoted_stop.add(ESCAPE_CHARACTER); + waiting_key_bytes.add(ESCAPE_CHARACTER); + key_stop_bytes.add(ESCAPE_CHARACTER); + value_stop_bytes.add(ESCAPE_CHARACTER); + quoted_stop_bytes.add(ESCAPE_CHARACTER); } } @@ -297,23 +331,27 @@ struct KeyValuePairExtractor::Impl if (pair_delimiters_str.contains(configuration.quoting_character)) throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, quoting_character conflicts with pair delimiters"); - if (configuration.with_escaping - && (configuration.key_value_delimiter == ESCAPE_CHARACTER + if (configuration.with_escaping) + { + bool used_escape_character = configuration.key_value_delimiter == ESCAPE_CHARACTER || configuration.quoting_character == ESCAPE_CHARACTER - || pair_delimiters_str.contains(ESCAPE_CHARACTER))) - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, {} is reserved for the escaping character", ESCAPE_CHARACTER); + || pair_delimiters_str.contains(ESCAPE_CHARACTER); + + if (used_escape_character) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid arguments, {} is reserved for the escaping character", ESCAPE_CHARACTER); + } } /// Reads a token starting at `pos` until a byte from `stop_set` and moves `pos` past that byte /// (or to `end`). With escaping, escape sequences are decoded into the token. template - Stop readToken(const ByteSet & stop_set, Token & token, std::string_view & result, const char *& pos, const char * end) const + Stop readToken(const ByteSet & stop_bytes, Token & token, std::string_view & result, const char *& pos, const char * end) const { token.start(pos); while (true) { - pos = stop_set.find(pos, end); + pos = stop_bytes.find(pos, end); if (pos == end) { result = token.finish(pos); @@ -327,6 +365,7 @@ struct KeyValuePairExtractor::Impl { if (token.consumeEscapeSequence(pos, end)) continue; + result = token.finish(pos); return Stop::InvalidEscapeSequence; } @@ -337,12 +376,100 @@ struct KeyValuePairExtractor::Impl if (c == configuration.key_value_delimiter) return Stop::KeyValueDelimiter; + if (c == configuration.quoting_character) return Stop::QuotingCharacter; + return Stop::PairDelimiter; } } + /// Reads an unquoted key starting at `pos`. Depending on the strategy, an unexpected quoting + /// character either discards the key or restarts it as a quoted key. + template + ReadResult readUnquotedKey(Token & key, std::string_view & key_view, const char *& pos, const char * end) const + { + Stop stop = readToken(key_stop_bytes, key, key_view, pos, end); + switch (stop) + { + case Stop::End: + return ReadResult::End; + case Stop::KeyValueDelimiter: + return ReadResult::Ok; + case Stop::PairDelimiter: + case Stop::InvalidEscapeSequence: + return ReadResult::Discard; + case Stop::QuotingCharacter: + { + if (configuration.unexpected_quoting_character_strategy == UnexpectedQuotingCharacterStrategy::INVALID) + return ReadResult::Discard; + + return readQuotedKey(key, key_view, pos, end); + } + } + } + + /// Reads a quoted key, `pos` is right after the opening quote. The key must be non-empty + /// and the closing quote must be followed by the key-value delimiter. + template + ReadResult readQuotedKey(Token & key, std::string_view & key_view, const char *& pos, const char * end) const + { + Stop stop = readToken(quoted_stop_bytes, key, key_view, pos, end); + if (stop == Stop::End) + return ReadResult::End; + + if (stop == Stop::InvalidEscapeSequence || key_view.empty()) + return ReadResult::Discard; + + if (pos == end || *pos != configuration.key_value_delimiter) + return ReadResult::Discard; + + ++pos; + return ReadResult::Ok; + } + + /// Reads an unquoted value starting at `pos`. It ends at a pair delimiter or at the end of the input; + /// an invalid escape sequence ends it as well and what was read so far is kept. Depending on the + /// strategy, an unexpected quoting character either discards the value or restarts it as a quoted value. + template + ReadResult readUnquotedValue(Token & value, std::string_view & value_view, const char *& pos, const char * end) const + { + if constexpr (with_escaping) + { + /// A value cannot start with an escape sequence. + if (pos != end && *pos == ESCAPE_CHARACTER) + return ReadResult::Discard; + } + + Stop stop = readToken(value_stop_bytes, value, value_view, pos, end); + if (stop != Stop::QuotingCharacter) + return ReadResult::Ok; + + if (configuration.unexpected_quoting_character_strategy == UnexpectedQuotingCharacterStrategy::INVALID) + return ReadResult::Discard; + + return readQuotedValue(value, value_view, pos, end); + } + + /// Reads a quoted value, `pos` is right after the opening quote. Only a pair delimiter may follow + /// the closing quote, everything up to it is skipped. + template + ReadResult readQuotedValue(Token & value, std::string_view & value_view, const char *& pos, const char * end) const + { + Stop stop = readToken(quoted_stop_bytes, value, value_view, pos, end); + if (stop == Stop::End) + return ReadResult::End; + + if (stop == Stop::InvalidEscapeSequence) + return ReadResult::Discard; + + pos = pair_delimiters.find(pos, end); + if (pos != end) + ++pos; + + return ReadResult::Ok; + } + template size_t extractImpl(std::string_view data, OnPair && on_pair) const { @@ -361,100 +488,58 @@ struct KeyValuePairExtractor::Impl auto commit = [&] { ++num_pairs; + if (configuration.max_number_of_pairs && num_pairs > configuration.max_number_of_pairs) throw Exception(ErrorCodes::LIMIT_EXCEEDED, "Number of pairs produced exceeded the limit of {}", configuration.max_number_of_pairs); + on_pair(key_view, value_view); }; - const auto strategy = configuration.unexpected_quoting_character_strategy; - while (true) { /// Waiting for a key. - pos = waiting_key_skip.find(pos, end); + pos = waiting_key_bytes.find(pos, end); if (pos == end) return num_pairs; - bool quoted_key = *pos == configuration.quoting_character; - if (quoted_key) + ReadResult key_result{}; + if (*pos == configuration.quoting_character) + { ++pos; + key_result = readQuotedKey(key, key_view, pos, end); + } else { - /// Reading an unquoted key. - Stop stop = readToken(key_stop, key, key_view, pos, end); - if (stop == Stop::End) - return num_pairs; - if (stop == Stop::PairDelimiter || stop == Stop::InvalidEscapeSequence) - continue; - if (stop == Stop::QuotingCharacter) - { - if (strategy == UnexpectedQuotingCharacterStrategy::INVALID) - continue; - /// PROMOTE: discard what was read and read a quoted key instead. - quoted_key = true; - } + key_result = readUnquotedKey(key, key_view, pos, end); } - if (quoted_key) - { - /// Reading a quoted key. - Stop stop = readToken(quoted_stop, key, key_view, pos, end); - if (stop == Stop::End) - return num_pairs; - if (stop == Stop::InvalidEscapeSequence || key_view.empty()) - continue; - /// The closing quote must be followed by the key-value delimiter. - if (pos == end || *pos != configuration.key_value_delimiter) - continue; - ++pos; - } + if (key_result == ReadResult::End) + return num_pairs; + + if (key_result == ReadResult::Discard) + continue; /// Waiting for a value. - bool quoted_value = false; - if (pos != end) + ReadResult value_result{}; + if (pos != end && *pos == configuration.quoting_character) { - if (*pos == configuration.quoting_character) - { - quoted_value = true; - ++pos; - } - else if (with_escaping && *pos == ESCAPE_CHARACTER) - { - /// A value cannot start with an escape sequence, the key is discarded. - continue; - } + ++pos; + value_result = readQuotedValue(value, value_view, pos, end); + } + else + { + value_result = readUnquotedValue(value, value_view, pos, end); } - if (!quoted_value) - { - /// Reading an unquoted value. It ends at a pair delimiter or at the end of the input. - /// An invalid escape sequence ends it as well and what was read so far is kept. - Stop stop = readToken(value_stop, value, value_view, pos, end); - if (stop != Stop::QuotingCharacter) - { - commit(); - if (pos == end) - return num_pairs; - continue; - } - if (strategy == UnexpectedQuotingCharacterStrategy::INVALID) - continue; - /// PROMOTE: discard what was read and read a quoted value instead. - } - - /// Reading a quoted value. - Stop stop = readToken(quoted_stop, value, value_view, pos, end); - if (stop == Stop::End) + if (value_result == ReadResult::End) return num_pairs; - if (stop == Stop::InvalidEscapeSequence) - continue; - commit(); - /// After a quoted value only a pair delimiter may follow, everything up to it is skipped. - pos = pair_delimiters.find(pos, end); + if (value_result == ReadResult::Discard) + continue; + + commit(); if (pos == end) return num_pairs; - ++pos; } } @@ -515,14 +600,18 @@ public: DataTypePtr getReturnTypeImpl(const ColumnsWithTypeAndName & arguments) const override { if (arguments.empty() || arguments.size() > MAX_NUMBER_OF_ARGUMENTS) + { throw Exception(ErrorCodes::NUMBER_OF_ARGUMENTS_DOESNT_MATCH, "Function {} requires at least 1 argument and at most {}. {} was provided", getName(), MAX_NUMBER_OF_ARGUMENTS, arguments.size()); + } for (size_t i = 0; i < arguments.size(); ++i) + { if (!isStringOrFixedString(arguments[i].type)) throw Exception(ErrorCodes::ILLEGAL_TYPE_OF_ARGUMENT, "Illegal type {} of argument {}. Must be String.", arguments[i].type, ARGUMENT_NAMES[i]); + } return std::make_shared(std::make_shared(), std::make_shared()); } From a6980273120b99858ea5b85d774c9f75bcf96eee Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Thu, 10 Sep 2026 23:07:25 +0000 Subject: [PATCH 073/272] Remove `SearchSymbols` and the runtime-needle search from `find_symbols.h` The `(std::string_view, const SearchSymbols &)` overloads of `find_first_symbols`, `find_first_not_symbols`, `find_*_symbols_or_null` and the machinery behind them (`SearchSymbols`, the runtime `is_in`, `mm_is_in_prepare`/`mm_is_in_execute`, `neon_is_in`, `*_neon_long_rt` and the runtime `find_*_symbols_sse2`/`sse42`) had a single user, the previous implementation of `extractKeyValuePairs`. The compile-time `find_first_symbols<...>` templates are untouched. The unit tests of the removed overloads are deleted; the remaining tests keep covering the compile-time templates. Co-Authored-By: Claude Fable 5.1 --- base/base/find_symbols.h | 357 ------------------------ src/Common/tests/gtest_find_symbols.cpp | 242 ---------------- src/Functions/trim.cpp | 4 +- 3 files changed, 2 insertions(+), 601 deletions(-) diff --git a/base/base/find_symbols.h b/base/base/find_symbols.h index 07d50200b1a1..a61999e1d19f 100644 --- a/base/base/find_symbols.h +++ b/base/base/find_symbols.h @@ -43,52 +43,10 @@ * Count the number of symbols of the set in a string. */ -struct SearchSymbols -{ - static constexpr auto BUFFER_SIZE = 16; - - SearchSymbols() = default; - - explicit SearchSymbols(std::string in) - : str(std::move(in)) - { - if (str.size() > BUFFER_SIZE) - { - throw std::runtime_error("SearchSymbols may contain at most " + std::to_string(BUFFER_SIZE) + " symbols but " + std::to_string(str.size()) + " symbols were provided"); - } - -#if defined(__SSE4_2__) - char tmp_safety_buffer[BUFFER_SIZE] = {0}; - - memcpy(tmp_safety_buffer, str.data(), str.size()); - - simd_vector = _mm_loadu_si128(reinterpret_cast(tmp_safety_buffer)); -#endif - } - -#if defined(__SSE4_2__) - __m128i simd_vector{}; -#endif - std::string str; -}; - namespace detail { template constexpr bool is_in(char x) { return ((x == chars) || ...); } // NOLINT(misc-redundant-expression) -static bool is_in(char c, const char * symbols, size_t num_chars) -{ - for (size_t i = 0u; i < num_chars; ++i) - { - if (c == symbols[i]) - { - return true; - } - } - - return false; -} - #if defined(__SSE2__) template inline __m128i mm_is_in(__m128i bytes) @@ -105,50 +63,6 @@ inline __m128i mm_is_in(__m128i bytes) return _mm_or_si128(eq0, eq); } -inline __m128i mm_is_in(__m128i bytes, const char * symbols, size_t num_chars) -{ - __m128i accumulator = _mm_setzero_si128(); - for (size_t i = 0; i < num_chars; ++i) - { - __m128i eq = _mm_cmpeq_epi8(bytes, _mm_set1_epi8(symbols[i])); - accumulator = _mm_or_si128(accumulator, eq); - } - - return accumulator; -} - -inline std::array<__m128i, 16u> mm_is_in_prepare(const char * symbols, size_t num_chars) -{ - std::array<__m128i, 16u> result {}; - - for (size_t i = 0; i < num_chars; ++i) - { - result[i] = _mm_set1_epi8(symbols[i]); - } - - /// Pad unused slots with a repeat of an actual needle byte so `mm_is_in_execute` - /// does not spuriously match `\0` bytes in the haystack via the zero-initialised - /// slots. Callers ensure `num_chars >= 1` before reaching the SIMD body. - for (size_t i = num_chars; i < 16u; ++i) - { - result[i] = result[0]; - } - - return result; -} - -inline __m128i mm_is_in_execute(__m128i bytes, const std::array<__m128i, 16u> & needles) -{ - __m128i accumulator = _mm_setzero_si128(); - - for (const auto & needle : needles) - { - __m128i eq = _mm_cmpeq_epi8(bytes, needle); - accumulator = _mm_or_si128(accumulator, eq); - } - - return accumulator; -} #endif #if defined(__aarch64__) @@ -171,18 +85,6 @@ inline uint8x16_t neon_is_in(uint8x16_t bytes) return vorrq_u8(eq0, eq); } -inline uint8x16_t neon_is_in(uint8x16_t bytes, const char * symbols, size_t num_chars) -{ - uint8x16_t accumulator = vdupq_n_u8(0); - for (size_t i = 0; i < num_chars; ++i) - { - uint8x16_t eq = vceqq_u8(bytes, vdupq_n_u8(static_cast(symbols[i]))); - accumulator = vorrq_u8(accumulator, eq); - } - - return accumulator; -} - /// Compresses a 16-byte all-0/all-1 vector into a 64-bit value where each input /// byte occupies a 4-bit nibble (all bits set if matched, all clear otherwise). inline uint64_t neon_to_bitmask(uint8x16_t eq) @@ -297,86 +199,6 @@ inline const char * find_first_symbols_sse2(const char * const begin, const char return return_mode == ReturnMode::End ? end : nullptr; } -#if defined(__aarch64__) -/// Runtime-needle NEON body for long haystacks. Always returns either a -/// matching pointer or `end` (treated by the caller as "no match"). Kept -/// out-of-line so short-haystack callers stay free of the SIMD prologue -/// (no stack frame, no extra branches). -template -[[gnu::noinline]] const char * find_first_symbols_neon_long_rt(const char * pos, const char * const end, const char * symbols, size_t num_chars) -{ - if (maybe_negate(is_in(pos[0], symbols, num_chars))) return pos; - if (maybe_negate(is_in(pos[1], symbols, num_chars))) return pos + 1; - if (maybe_negate(is_in(pos[2], symbols, num_chars))) return pos + 2; - if (maybe_negate(is_in(pos[3], symbols, num_chars))) return pos + 3; - if (maybe_negate(is_in(pos[4], symbols, num_chars))) return pos + 4; - if (maybe_negate(is_in(pos[5], symbols, num_chars))) return pos + 5; - if (maybe_negate(is_in(pos[6], symbols, num_chars))) return pos + 6; - if (maybe_negate(is_in(pos[7], symbols, num_chars))) return pos + 7; - pos += 8; - - for (; pos + 15 < end; pos += 16) - { - uint8x16_t bytes = vld1q_u8(reinterpret_cast(pos)); - - uint8x16_t eq = maybe_negate(neon_is_in(bytes, symbols, num_chars)); - - uint64_t bit_mask = neon_to_bitmask(eq); - if (bit_mask) - return pos + (__builtin_ctzll(bit_mask) >> 2); - } - - for (; pos < end; ++pos) - if (maybe_negate(is_in(*pos, symbols, num_chars))) - return pos; - - return end; -} -#endif - - -template -inline const char * find_first_symbols_sse2(const char * const begin, const char * const end, const char * symbols, size_t num_chars) -{ - const char * pos = begin; - -#if defined(__SSE2__) - if (pos + 15 < end) - { - const auto needles = mm_is_in_prepare(symbols, num_chars); - for (; pos + 15 < end; pos += 16) - { - __m128i bytes = _mm_loadu_si128(reinterpret_cast(pos)); - - __m128i eq = mm_is_in_execute(bytes, needles); - - uint16_t bit_mask = maybe_negate(uint16_t(_mm_movemask_epi8(eq))); - if (bit_mask) - return pos + __builtin_ctz(bit_mask); - } - } -#elif defined(__aarch64__) - /// See the compile-time variant above for why the SIMD body is out of line. - /// `neon_is_in` (rather than the prepare/execute pair) avoids forcing the - /// compiler to reserve stack space for a `std::array` in - /// every call to this function. - if (end - begin >= 16) [[unlikely]] - { - const char * found = find_first_symbols_neon_long_rt(begin, end, symbols, num_chars); - if (found != end) - return found; - return return_mode == ReturnMode::End ? end : nullptr; - } -#endif - - for (; pos < end; ++pos) - if (maybe_negate(is_in(*pos, symbols, num_chars))) - return pos; - - return return_mode == ReturnMode::End ? end : nullptr; -} - - #if defined(__aarch64__) /// See the forward variant above. Out-of-lined for the same reason. /// Always returns either a matching pointer or `nullptr`; the inline caller @@ -449,83 +271,6 @@ inline const char * find_last_symbols_sse2(const char * const begin, const char return return_mode == ReturnMode::End ? end : nullptr; } -#if defined(__aarch64__) -/// Runtime-needle NEON body for long haystacks (find_last). Returns either a -/// matching pointer or `nullptr`. Kept out-of-line for the same reasons as -/// the forward variant. -template -[[gnu::noinline]] const char * find_last_symbols_neon_long_rt(const char * const begin, const char * pos, const char * symbols, size_t num_chars) -{ - if (maybe_negate(is_in(pos[-1], symbols, num_chars))) return pos - 1; - if (maybe_negate(is_in(pos[-2], symbols, num_chars))) return pos - 2; - if (maybe_negate(is_in(pos[-3], symbols, num_chars))) return pos - 3; - if (maybe_negate(is_in(pos[-4], symbols, num_chars))) return pos - 4; - if (maybe_negate(is_in(pos[-5], symbols, num_chars))) return pos - 5; - if (maybe_negate(is_in(pos[-6], symbols, num_chars))) return pos - 6; - if (maybe_negate(is_in(pos[-7], symbols, num_chars))) return pos - 7; - if (maybe_negate(is_in(pos[-8], symbols, num_chars))) return pos - 8; - pos -= 8; - - for (; pos - 16 >= begin; pos -= 16) - { - uint8x16_t bytes = vld1q_u8(reinterpret_cast(pos - 16)); - - uint8x16_t eq = maybe_negate(neon_is_in(bytes, symbols, num_chars)); - - uint64_t bit_mask = neon_to_bitmask(eq); - if (bit_mask) - return pos - 1 - (__builtin_clzll(bit_mask) >> 2); - } - - --pos; - for (; pos >= begin; --pos) - if (maybe_negate(is_in(*pos, symbols, num_chars))) - return pos; - - return nullptr; -} -#endif - - -template -inline const char * find_last_symbols_sse2(const char * const begin, const char * const end, const char * symbols, size_t num_chars) -{ - const char * pos = end; - -#if defined(__SSE2__) - if (pos - 16 >= begin) - { - const auto needles = mm_is_in_prepare(symbols, num_chars); - for (; pos - 16 >= begin; pos -= 16) - { - __m128i bytes = _mm_loadu_si128(reinterpret_cast(pos - 16)); - - __m128i eq = mm_is_in_execute(bytes, needles); - - uint16_t bit_mask = maybe_negate(uint16_t(_mm_movemask_epi8(eq))); - if (bit_mask) - return pos - 1 - (__builtin_clz(bit_mask) - 16); /// because __builtin_clz works with mask as uint32. - } - } -#elif defined(__aarch64__) - /// See the forward variant above. Out-of-lined for the same reason. - if (end - begin >= 16) [[unlikely]] - { - const char * found = find_last_symbols_neon_long_rt(begin, end, symbols, num_chars); - if (found) - return found; - return return_mode == ReturnMode::End ? end : nullptr; - } -#endif - - --pos; - for (; pos >= begin; --pos) - if (maybe_negate(is_in(*pos, symbols, num_chars))) - return pos; - - return return_mode == ReturnMode::End ? end : nullptr; -} - template -inline const char * find_first_symbols_sse42(const char * const begin, const char * const end, const SearchSymbols & symbols) -{ - const char * pos = begin; - - const auto num_chars = symbols.str.size(); - -#if defined(__SSE4_2__) - constexpr int mode = _SIDD_UBYTE_OPS | _SIDD_CMP_EQUAL_ANY | _SIDD_LEAST_SIGNIFICANT; - - const __m128i set = symbols.simd_vector; - - for (; pos + 15 < end; pos += 16) - { - __m128i bytes = _mm_loadu_si128(reinterpret_cast(pos)); - - if constexpr (positive) - { - if (_mm_cmpestrc(set, num_chars, bytes, 16, mode)) - return pos + _mm_cmpestri(set, num_chars, bytes, 16, mode); - } - else - { - if (_mm_cmpestrc(set, num_chars, bytes, 16, mode | _SIDD_NEGATIVE_POLARITY)) - return pos + _mm_cmpestri(set, num_chars, bytes, 16, mode | _SIDD_NEGATIVE_POLARITY); - } - } -#endif - - for (; pos < end; ++pos) - if (maybe_negate(is_in(*pos, symbols.str.data(), num_chars))) - return pos; - - return return_mode == ReturnMode::End ? end : nullptr; -} - /// NOTE No SSE 4.2 implementation for find_last_symbols_or_null. Not worth to do. template @@ -627,28 +336,6 @@ inline const char * find_first_symbols_dispatch(const char * begin, const char * return find_first_symbols_sse2(begin, end); } -template -inline const char * find_first_symbols_dispatch(const std::string_view haystack, const SearchSymbols & symbols) -{ - /// Empty needle: no byte is in the (empty) symbol set. For positive search - /// nothing is found; for the negative variant every byte qualifies and we - /// return the first one. Bypassing the SIMD body here keeps `mm_is_in_prepare` - /// and `neon_is_in` free of an extra branch and guards against the zero-pad - /// degenerate case (a zero-filled needle vector would otherwise match `\0`). - if (symbols.str.empty()) - { - if constexpr (!positive) - if (!haystack.empty()) - return haystack.data(); - return return_mode == ReturnMode::End ? haystack.data() + haystack.size() : nullptr; - } -#if defined(__SSE4_2__) - if (symbols.str.size() >= 5) - return find_first_symbols_sse42(haystack.data(), haystack.data() + haystack.size(), symbols); -#endif - return find_first_symbols_sse2(haystack.data(), haystack.data() + haystack.size(), symbols.str.data(), symbols.str.size()); -} - template inline const char * find_last_symbols_dispatch(const char * begin, const char * end) requires(0 <= sizeof...(symbols) && sizeof...(symbols) <= 16) @@ -656,20 +343,6 @@ inline const char * find_last_symbols_dispatch(const char * begin, const char * return find_last_symbols_sse2(begin, end); } -template -inline const char * find_last_symbols_dispatch(const std::string_view haystack, const SearchSymbols & symbols) -{ - /// See the forward dispatcher above. - if (symbols.str.empty()) - { - if constexpr (!positive) - if (!haystack.empty()) - return haystack.data() + haystack.size() - 1; - return return_mode == ReturnMode::End ? haystack.data() + haystack.size() : nullptr; - } - return find_last_symbols_sse2(haystack.data(), haystack.data() + haystack.size(), symbols.str.data(), symbols.str.size()); -} - } @@ -687,11 +360,6 @@ inline char * find_first_symbols(char * begin, char * end) return const_cast(detail::find_first_symbols_dispatch(begin, end)); } -inline const char * find_first_symbols(std::string_view haystack, const SearchSymbols & symbols) -{ - return detail::find_first_symbols_dispatch(haystack, symbols); -} - template inline const char * find_first_not_symbols(const char * begin, const char * end) { @@ -704,11 +372,6 @@ inline char * find_first_not_symbols(char * begin, char * end) return const_cast(detail::find_first_symbols_dispatch(begin, end)); } -inline const char * find_first_not_symbols(std::string_view haystack, const SearchSymbols & symbols) -{ - return detail::find_first_symbols_dispatch(haystack, symbols); -} - template inline const char * find_first_symbols_or_null(const char * begin, const char * end) { @@ -721,11 +384,6 @@ inline char * find_first_symbols_or_null(char * begin, char * end) return const_cast(detail::find_first_symbols_dispatch(begin, end)); } -inline const char * find_first_symbols_or_null(std::string_view haystack, const SearchSymbols & symbols) -{ - return detail::find_first_symbols_dispatch(haystack, symbols); -} - template inline const char * find_first_not_symbols_or_null(const char * begin, const char * end) { @@ -738,11 +396,6 @@ inline char * find_first_not_symbols_or_null(char * begin, char * end) return const_cast(detail::find_first_symbols_dispatch(begin, end)); } -inline const char * find_first_not_symbols_or_null(std::string_view haystack, const SearchSymbols & symbols) -{ - return detail::find_first_symbols_dispatch(haystack, symbols); -} - template inline const char * find_last_symbols_or_null(const char * begin, const char * end) { @@ -755,11 +408,6 @@ inline char * find_last_symbols_or_null(char * begin, char * end) return const_cast(detail::find_last_symbols_dispatch(begin, end)); } -inline const char * find_last_symbols_or_null(std::string_view haystack, const SearchSymbols & symbols) -{ - return detail::find_last_symbols_dispatch(haystack, symbols); -} - template inline const char * find_last_not_symbols_or_null(const char * begin, const char * end) { @@ -772,11 +420,6 @@ inline char * find_last_not_symbols_or_null(char * begin, char * end) return const_cast(detail::find_last_symbols_dispatch(begin, end)); } -inline const char * find_last_not_symbols_or_null(std::string_view haystack, const SearchSymbols & symbols) -{ - return detail::find_last_symbols_dispatch(haystack, symbols); -} - template inline size_t count_symbols(const char * begin, const char * end) { diff --git a/src/Common/tests/gtest_find_symbols.cpp b/src/Common/tests/gtest_find_symbols.cpp index 9f535c6b352a..e982c5e186fb 100644 --- a/src/Common/tests/gtest_find_symbols.cpp +++ b/src/Common/tests/gtest_find_symbols.cpp @@ -13,13 +13,6 @@ void test_find_first_not(const std::string & haystack, std::size_t expected_pos) ASSERT_EQ(begin + expected_pos, find_first_not_symbols(begin, end)); } -static void test_find_first_not(const std::string & haystack, const std::string & symbols, const std::size_t expected_pos) -{ - const char * begin = haystack.data(); - - ASSERT_EQ(begin + expected_pos, find_first_not_symbols(haystack, SearchSymbols(symbols))); -} - TEST(FindSymbols, SimpleTest) { @@ -57,92 +50,6 @@ TEST(FindSymbols, SimpleTest) } } -TEST(FindSymbols, RunTimeNeedle) -{ - auto test_haystack = [](const auto & haystack, const auto & unfindable_needle) { -#define TEST_HAYSTACK_AND_NEEDLE(haystack_, needle_) \ - do { \ - const auto & h = haystack_; \ - const auto & n = needle_; \ - EXPECT_EQ( \ - std::find_first_of(h.data(), h.data() + h.size(), n.data(), n.data() + n.size()), \ - find_first_symbols(h, SearchSymbols(n)) \ - ) << "haystack: \"" << h << "\" (" << static_cast(h.data()) << ")" \ - << ", needle: \"" << n << "\""; \ - } \ - while (false) - - // can't find needle - TEST_HAYSTACK_AND_NEEDLE(haystack, unfindable_needle); - -#define TEST_WITH_MODIFIED_NEEDLE(haystack, in_needle, needle_update_statement) \ - do \ - { \ - std::string needle = (in_needle); \ - (needle_update_statement); \ - TEST_HAYSTACK_AND_NEEDLE(haystack, needle); \ - } \ - while (false) - - // findable symbol is at beginning of the needle - // Can find at first pos of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle.front() = haystack.front()); - // Can find at first pos of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle.front() = haystack.back()); - // Can find in the middle of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle.front() = haystack[haystack.size() / 2]); - - // findable symbol is at end of the needle - // Can find at first pos of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle.back() = haystack.front()); - // Can find at first pos of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle.back() = haystack.back()); - // Can find in the middle of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle.back() = haystack[haystack.size() / 2]); - - // findable symbol is in the middle of the needle - // Can find at first pos of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle[needle.size() / 2] = haystack.front()); - // Can find at first pos of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle[needle.size() / 2] = haystack.back()); - // Can find in the middle of haystack - TEST_WITH_MODIFIED_NEEDLE(haystack, unfindable_needle, needle[needle.size() / 2] = haystack[haystack.size() / 2]); - -#undef TEST_WITH_MODIFIED_NEEDLE -#undef TEST_HAYSTACK_AND_NEEDLE - }; - - // there are 4 major groups of cases: - // haystack < 16 bytes, haystack > 16 bytes - // needle < 5 bytes, needle >= 5 bytes - - // First and last symbols of haystack should be unique - const std::string long_haystack = "Hello, world! Goodbye...?"; - const std::string short_haystack = "Hello, world!"; - - // In sync with find_first_symbols_dispatch code: long needles receive special treatment. - // as of now "long" means >= 5 - const std::string unfindable_long_needle = "0123456789ABCDEF"; - const std::string unfindable_short_needle = "0123"; - - { - SCOPED_TRACE("Long haystack"); - test_haystack(long_haystack, unfindable_long_needle); - test_haystack(long_haystack, unfindable_short_needle); - } - - { - SCOPED_TRACE("Short haystack"); - test_haystack(short_haystack, unfindable_long_needle); - test_haystack(short_haystack, unfindable_short_needle); - } - - // SearchSymbols rejects needles longer than SearchSymbols::BUFFER_SIZE on every platform, - // not only where the SSE4.2 path is compiled in. - const std::string excessively_long_needle = "ABCDEFIJKLMNOPQRSTUVWXYZacfghijkmnpqstuvxz"; - ASSERT_ANY_THROW(SearchSymbols{excessively_long_needle}); -} - TEST(FindNotSymbols, AllSymbolsPresent) { std::string str_with_17_bytes = "hello world hello"; @@ -161,12 +68,6 @@ TEST(FindNotSymbols, AllSymbolsPresent) test_find_first_not<'h', 'e', 'l', 'o', 'w', 'r', 'd', ' '>(str_with_15_bytes, str_with_15_bytes.size()); test_find_first_not<'h', 'e', 'l', 'o', 'w', 'r', 'd', ' '>(str_with_16_bytes, str_with_16_bytes.size()); test_find_first_not<'h', 'e', 'l', 'o', 'w', 'r', 'd', ' '>(str_with_17_bytes, str_with_17_bytes.size()); - - const auto * symbols = "helowrd "; - - test_find_first_not(str_with_15_bytes, symbols, str_with_15_bytes.size()); - test_find_first_not(str_with_16_bytes, symbols, str_with_16_bytes.size()); - test_find_first_not(str_with_17_bytes, symbols, str_with_17_bytes.size()); } TEST(FindNotSymbols, NoSymbolsMatch) @@ -175,28 +76,24 @@ TEST(FindNotSymbols, NoSymbolsMatch) // begin should be returned since the first character of the string does not match any of the below symbols test_find_first_not<'h', 'i', 'j'>(s, 0u); - test_find_first_not(s, "hij", 0u); } TEST(FindNotSymbols, ExtraSymbols) { std::string s = "hello_world_hello"; test_find_first_not<'h', 'e', 'l', 'o', ' '>(s, 5u); - test_find_first_not(s, "helo ", 5u); } TEST(FindNotSymbols, EmptyString) { std::string s; test_find_first_not<'h', 'e', 'l', 'o', 'w', 'r', 'd', ' '>(s, s.size()); - test_find_first_not(s, "helowrd ", s.size()); } TEST(FindNotSymbols, SingleChar) { std::string s = "a"; test_find_first_not<'a'>(s, s.size()); - test_find_first_not(s, "a", s.size()); } TEST(FindNotSymbols, NullCharacter) @@ -206,148 +103,9 @@ TEST(FindNotSymbols, NullCharacter) // to \0. std::string s("abcdefg\0x", 9u); test_find_first_not<'a', 'b', 'c', 'd', 'e', 'f', 'g'>(s, 7u); - test_find_first_not(s, "abcdefg", 7u); // Same check with a haystack long enough to exercise the SIMD body — guards against // implementations that pad unused needle slots with \0 and falsely match it. std::string long_s("abcdefgabcdefgab\0", 17u); test_find_first_not<'a', 'b', 'c', 'd', 'e', 'f', 'g'>(long_s, 16u); - test_find_first_not(long_s, "abcdefg", 16u); -} - -TEST(FindSymbols, EmptyRunTimeNeedle) -{ - // Empty SearchSymbols. With `positive=true` the result must be end/nullptr - // (no byte is in the empty symbol set); with `positive=false` the first/last - // byte qualifies. Long haystacks (>= 16 bytes) and embedded `\0` bytes also - // guard against the SIMD body matching `\0` via a zero-padded needle vector. - - const SearchSymbols empty{}; - - auto end_of = [](const std::string & h) { return h.data() + h.size(); }; - - // Short haystack, no `\0`. - { - const std::string h = "abc"; - EXPECT_EQ(find_first_symbols(h, empty), end_of(h)); - EXPECT_EQ(find_first_not_symbols(h, empty), h.data()); - EXPECT_EQ(find_first_symbols_or_null(h, empty), nullptr); - EXPECT_EQ(find_first_not_symbols_or_null(h, empty), h.data()); - EXPECT_EQ(find_last_symbols_or_null(h, empty), nullptr); - EXPECT_EQ(find_last_not_symbols_or_null(h, empty), end_of(h) - 1); - } - - // Empty haystack. - { - const std::string h; - EXPECT_EQ(find_first_symbols(h, empty), end_of(h)); - EXPECT_EQ(find_first_not_symbols(h, empty), end_of(h)); - EXPECT_EQ(find_first_symbols_or_null(h, empty), nullptr); - EXPECT_EQ(find_first_not_symbols_or_null(h, empty), nullptr); - EXPECT_EQ(find_last_symbols_or_null(h, empty), nullptr); - EXPECT_EQ(find_last_not_symbols_or_null(h, empty), nullptr); - } - - // Long haystack (exercises SIMD body) containing a `\0`. A zero-padded needle - // vector would falsely match the `\0`; the empty-needle fast path must skip - // SIMD entirely. - { - const std::string h("aaaaaaaaaaaaaaaa\0aaaaaaaaaaaaaaa", 32u); - ASSERT_EQ(h.size(), 32u); - EXPECT_EQ(find_first_symbols(h, empty), end_of(h)); - EXPECT_EQ(find_first_symbols_or_null(h, empty), nullptr); - EXPECT_EQ(find_last_symbols_or_null(h, empty), nullptr); - - EXPECT_EQ(find_first_not_symbols(h, empty), h.data()); - EXPECT_EQ(find_first_not_symbols_or_null(h, empty), h.data()); - EXPECT_EQ(find_last_not_symbols_or_null(h, empty), end_of(h) - 1); - } -} - -TEST(FindLastSymbols, RunTimeNeedleLongHaystack) -{ - // These exercise find_last_symbols_or_null(string_view, SearchSymbols) and - // find_last_not_symbols_or_null(string_view, SearchSymbols) on haystacks - // long enough to enter the SIMD reverse-search body (end - begin >= 16). - // The reverse path computes the match index as `__builtin_clzll(bit_mask) >> 2` - // on AArch64, so we cover the boundary positions and the \0 case. - - auto offset = [](const char * p, const std::string & h) -> ssize_t - { - return p == nullptr ? -1 : p - h.data(); - }; - - // 32 bytes — two full SIMD chunks, no scalar tail. - { - const std::string haystack = "abcdefghijklmnop0123456789ABCDEF"; - ASSERT_EQ(haystack.size(), 32u); - - // Match in the second (most-recent, scanned-first) SIMD chunk. - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("F")), haystack), 31); - // Match only in the first SIMD chunk (must skip the empty second chunk). - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("a")), haystack), 0); - // Match in the middle of the haystack (first byte of the second chunk). - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("0")), haystack), 16); - // Last byte of the first chunk (boundary). - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("p")), haystack), 15); - // Multiple matches: must return the rightmost one (second chunk, position 22). - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("ag6")), haystack), 22); - // No match — must return nullptr. - EXPECT_EQ(find_last_symbols_or_null(haystack, SearchSymbols("xyz")), nullptr); - } - - // 17 bytes — minimal length that takes the SIMD branch (one chunk + 1-byte scalar tail). - { - const std::string haystack = "0123456789abcdefX"; - ASSERT_EQ(haystack.size(), 17u); - - // Tail byte (handled by scalar prologue of the reverse helper, pos[-1]). - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("X")), haystack), 16); - // First byte of the haystack. - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("0")), haystack), 0); - // Match inside the SIMD chunk, not in the prologue. - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols("5")), haystack), 5); - } - - // \0 byte in the haystack: a runtime needle of "\0" must find it; an unrelated - // needle must not falsely match the embedded \0. This guards against any padding - // of unused needle slots with \0. - { - const std::string haystack("abcdefghijklmnop\0qrstuvwxyz", 27u); - ASSERT_EQ(haystack.size(), 27u); - - EXPECT_EQ(offset(find_last_symbols_or_null(haystack, SearchSymbols(std::string("\0", 1u))), haystack), 16); - EXPECT_EQ(find_last_symbols_or_null(haystack, SearchSymbols("0123")), nullptr); - } - - // Negative variant (find_last_not_symbols_or_null) with a runtime needle. - { - // 17 bytes, last byte differs from the needle — that byte should be returned - // by the scalar prologue (pos[-1]). - const std::string haystack = "aaaaaaaaaaaaaaaab"; - ASSERT_EQ(haystack.size(), 17u); - EXPECT_EQ(offset(find_last_not_symbols_or_null(haystack, SearchSymbols("a")), haystack), 16); - - // First byte differs from the needle, rest matches the needle — must be found - // in the SIMD body of the reverse pass. - const std::string haystack2 = "Xaaaaaaaaaaaaaaaa"; - ASSERT_EQ(haystack2.size(), 17u); - EXPECT_EQ(offset(find_last_not_symbols_or_null(haystack2, SearchSymbols("a")), haystack2), 0); - - // 32 bytes with a single non-needle byte exactly at the SIMD chunk boundary - // (position 15 — last byte of the first reverse-scanned chunk). - const std::string haystack3 = "aaaaaaaaaaaaaaaXaaaaaaaaaaaaaaaa"; - ASSERT_EQ(haystack3.size(), 32u); - EXPECT_EQ(offset(find_last_not_symbols_or_null(haystack3, SearchSymbols("a")), haystack3), 15); - - // All bytes match the needle — must return nullptr. - const std::string haystack4(32u, 'a'); - EXPECT_EQ(find_last_not_symbols_or_null(haystack4, SearchSymbols("a")), nullptr); - - // \0 byte: with needle "a", an embedded \0 is a non-needle byte and must be - // found by find_last_not. - const std::string haystack5("aaaaaaaaaaaaaaaa\0aaaaaaaaaaaaaaa", 32u); - ASSERT_EQ(haystack5.size(), 32u); - EXPECT_EQ(offset(find_last_not_symbols_or_null(haystack5, SearchSymbols("a")), haystack5), 16); - } } diff --git a/src/Functions/trim.cpp b/src/Functions/trim.cpp index dc1bf67c8a47..9b1de3ce1fa8 100644 --- a/src/Functions/trim.cpp +++ b/src/Functions/trim.cpp @@ -21,8 +21,8 @@ namespace { /// Membership table for the optional second argument of trim* functions. -/// Unlike `SearchSymbols` (a SIMD primitive capped at 16 symbols), it supports -/// a trim character set of any length and looks up each byte in O(1). +/// Unlike `find_first_symbols` (a SIMD primitive with a compile-time set of at most +/// 16 symbols), it supports a trim character set of any length and looks up each byte in O(1). using TrimCharsTable = std::array; class FunctionTrim final : public IFunction From 9993eea304d9acc2fef0d61e181daf9861be689c Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Thu, 10 Sep 2026 23:15:44 +0000 Subject: [PATCH 074/272] slightly better --- src/Functions/extractKeyValuePairs.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index a969a00d98cf..bec916fce4bc 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -49,8 +49,8 @@ constexpr char ESCAPE_CHARACTER = '\\'; #if defined(__SSSE3__) || defined(__aarch64__) -/// GCC/Clang vector extensions lower each lane operation to one SSE/NEON instruction; -/// only the byte permutation has no portable spelling. +/// GCC/Clang vector extensions lower each lane operation to one SSE/NEON instruction. +/// Only the byte permutation has no portable spelling. using UInt8x16 = UInt8 __attribute__((vector_size(16))); using UInt64x2 = UInt64 __attribute__((vector_size(16))); @@ -131,10 +131,8 @@ public: return pos; } -#if defined(__SSSE3__) || defined(__aarch64__) if (vectorized) pos = findVectorized(pos, end); -#endif for (; pos < end; ++pos) { @@ -149,12 +147,12 @@ private: static constexpr ptrdiff_t SCALAR_PREFIX = 16; static constexpr ptrdiff_t VECTOR_SIZE = 16; -#if defined(__SSSE3__) || defined(__aarch64__) /// Scans whole 16-byte blocks. Returns the position of the first byte matching the search, /// or the position from which fewer than 16 bytes remain. template const char * findVectorized(const char * pos, const char * end) const { +#if defined(__SSSE3__) || defined(__aarch64__) const auto low_table = std::bit_cast(low_nibble_table); const auto high_table = std::bit_cast(high_nibble_table); @@ -178,10 +176,10 @@ private: if (halves[1]) return pos + 8 + std::countr_zero(halves[1]) / 8; } +#endif return pos; } -#endif bool table[256]{}; UInt8 low_nibble_table[16]{}; From 510da4d7625456af3ae56a642bbd842e966e497d Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Thu, 10 Sep 2026 23:35:08 +0000 Subject: [PATCH 075/272] Clean up the documentation examples of `extractKeyValuePairs` Remove the interactive client prompts and query ids from the examples in the function documentation. Co-Authored-By: Claude Fable 5.1 --- src/Functions/extractKeyValuePairs.cpp | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index bec916fce4bc..ae8e1b283997 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -735,11 +735,8 @@ Query: **Simple case** ```sql -arthur :) select extractKeyValuePairs('name:neymar, age:31 team:psg,nationality:brazil') as kv -SELECT extractKeyValuePairs('name:neymar, age:31 team:psg,nationality:brazil') as kv - -Query id: f9e0ca6f-3178-4ee2-aa2c-a5517abb9cee +SELECT extractKeyValuePairs('name:neymar, age:31 team:psg,nationality:brazil') as kv; ┌─kv──────────────────────────────────────────────────────────────────────â”� │ {'name':'neymar','age':'31','team':'psg','nationality':'brazil'} │ @@ -748,11 +745,7 @@ Query id: f9e0ca6f-3178-4ee2-aa2c-a5517abb9cee **Single quote as quoting character** ```sql -arthur :) select extractKeyValuePairs('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\'') as kv - -SELECT extractKeyValuePairs('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\'') as kv - -Query id: 0e22bf6b-9844-414a-99dc-32bf647abd5e +SELECT extractKeyValuePairs('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\'') as kv; ┌─kv───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────â”� │ {'name':'neymar','age':'31','team':'psg','nationality':'brazil','last_key':'last_value'} │ @@ -862,12 +855,8 @@ Leading escape sequences will be skipped in keys and will be considered invalid **Escape sequences with escape sequence support turned on** ```sql -arthur :) select extractKeyValuePairsWithEscaping('age:a\\x0A\\n\\0') as kv - SELECT extractKeyValuePairsWithEscaping('age:a\\x0A\\n\\0') AS kv -Query id: 44c114f0-5658-4c75-ab87-4574de3a1645 - ┌─kv────────────────â”� │ {'age':'a\n\n\0'} │ └───────────────────┘ From 9e54883457a291f8d6c2d5b65bd55a86e3fe7df5 Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Fri, 11 Sep 2026 01:49:18 +0200 Subject: [PATCH 076/272] better docs --- src/Functions/extractKeyValuePairs.cpp | 40 ++++++++------------------ 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index bec916fce4bc..9372fc10d53c 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -735,27 +735,19 @@ Query: **Simple case** ```sql -arthur :) select extractKeyValuePairs('name:neymar, age:31 team:psg,nationality:brazil') as kv - -SELECT extractKeyValuePairs('name:neymar, age:31 team:psg,nationality:brazil') as kv - -Query id: f9e0ca6f-3178-4ee2-aa2c-a5517abb9cee +SELECT extractKeyValuePairs('name:neymar, age:34 team:santos,nationality:brazil') AS kv; ┌─kv──────────────────────────────────────────────────────────────────────â”� -│ {'name':'neymar','age':'31','team':'psg','nationality':'brazil'} │ +│ {'name':'neymar','age':'34','team':'santos','nationality':'brazil'} │ └─────────────────────────────────────────────────────────────────────────┘ ``` **Single quote as quoting character** ```sql -arthur :) select extractKeyValuePairs('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\'') as kv - -SELECT extractKeyValuePairs('name:\'neymar\';\'age\':31;team:psg;nationality:brazil,last_key:last_value', ':', ';,', '\'') as kv - -Query id: 0e22bf6b-9844-414a-99dc-32bf647abd5e +SELECT extractKeyValuePairs('name:\'neymar\';\'age\':34;team:santos;nationality:brazil,last_key:last_value', ':', ';,', '\'') AS kv; ┌─kv───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────â”� -│ {'name':'neymar','age':'31','team':'psg','nationality':'brazil','last_key':'last_value'} │ +│ {'name':'neymar','age':'34','team':'santos','nationality':'brazil','last_key':'last_value'} │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ``` @@ -764,7 +756,7 @@ unexpected_quoting_character_strategy examples: unexpected_quoting_character_strategy=invalid ```sql -SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'INVALID') as kv; +SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'INVALID') AS kv; ``` ```text @@ -774,7 +766,7 @@ SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'INVALID') as kv; ``` ```sql -SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'INVALID') as kv; +SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'INVALID') AS kv; ``` ```text @@ -786,7 +778,7 @@ SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'INVALID') as kv; unexpected_quoting_character_strategy=accept ```sql -SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'ACCEPT') as kv; +SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'ACCEPT') AS kv; ``` ```text @@ -796,7 +788,7 @@ SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'ACCEPT') as kv; ``` ```sql -SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'ACCEPT') as kv; +SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'ACCEPT') AS kv; ``` ```text @@ -808,7 +800,7 @@ SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'ACCEPT') as kv; unexpected_quoting_character_strategy=promote ```sql -SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'PROMOTE') as kv; +SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'PROMOTE') AS kv; ``` ```text @@ -818,7 +810,7 @@ SELECT extractKeyValuePairs('name"abc:5', ':', ' ,;', '\"', 'PROMOTE') as kv; ``` ```sql -SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'PROMOTE') as kv; +SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'PROMOTE') AS kv; ``` ```text @@ -829,11 +821,7 @@ SELECT extractKeyValuePairs('name"abc":5', ':', ' ,;', '\"', 'PROMOTE') as kv; **Escape sequences without escape sequences support** ```sql -arthur :) select extractKeyValuePairs('age:a\\x0A\\n\\0') as kv - -SELECT extractKeyValuePairs('age:a\\x0A\\n\\0') AS kv - -Query id: e9fd26ee-b41f-4a11-b17f-25af6fd5d356 +SELECT extractKeyValuePairs('age:a\\x0A\\n\\0') AS kv; ┌─kv─────────────────────â”� │ {'age':'a\\x0A\\n\\0'} │ @@ -862,11 +850,7 @@ Leading escape sequences will be skipped in keys and will be considered invalid **Escape sequences with escape sequence support turned on** ```sql -arthur :) select extractKeyValuePairsWithEscaping('age:a\\x0A\\n\\0') as kv - -SELECT extractKeyValuePairsWithEscaping('age:a\\x0A\\n\\0') AS kv - -Query id: 44c114f0-5658-4c75-ab87-4574de3a1645 +SELECT extractKeyValuePairsWithEscaping('age:a\\x0A\\n\\0') AS kv; ┌─kv────────────────â”� │ {'age':'a\n\n\0'} │ From 415f3733b2ef23ccff49eea4afeae15a54f931ac Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Fri, 11 Sep 2026 00:22:30 +0000 Subject: [PATCH 077/272] Move the byte set scanner of `extractKeyValuePairs` to `Common/ByteSetLookup.h` The class is renamed to `ByteSetLookup` and becomes a shared header, so other byte-class scanners (for example the text index tokenizers) can use it. `extractKeyValuePairs.cpp` keeps only the extractor itself. Co-Authored-By: Claude Fable 5.1 --- src/Common/ByteSetLookup.h | 161 ++++++++++++++++++++++++ src/Functions/extractKeyValuePairs.cpp | 165 ++----------------------- 2 files changed, 168 insertions(+), 158 deletions(-) create mode 100644 src/Common/ByteSetLookup.h diff --git a/src/Common/ByteSetLookup.h b/src/Common/ByteSetLookup.h new file mode 100644 index 000000000000..69dd89f2eda5 --- /dev/null +++ b/src/Common/ByteSetLookup.h @@ -0,0 +1,161 @@ +#pragma once + +#include +#include + +#include +#include + +#if defined(__SSSE3__) +# include +#endif +#if defined(__aarch64__) +# include +#endif + +namespace DB +{ + +/// A set of bytes with an O(1) membership test and a vectorized search for the first byte that +/// is (or is not) in the set. Building a set is cheap, so it fits delimiter sets that come from +/// user input as well as character classes fixed at compile time. +/// +/// The vectorized search can only look up 16-entry tables and therefore classifies a byte by +/// its two nibbles: +/// +/// - Every high nibble that occurs in the set gets its own bit (`high_nibble_bit`). +/// - `high_nibble_table[high]` is that bit, or 0 if no member has this high nibble. +/// - `low_nibble_table[low]` is the union of the bits of all high nibbles that are +/// combined with this low nibble in some member. +/// - A byte is in the set iff the two lookups share a bit: +/// `low_nibble_table[low] & high_nibble_table[high] != 0`. +/// +/// The bits fit in one byte, so at most 8 distinct high nibbles are supported. That is +/// enough for any set of ASCII characters; a set that needs more is searched by the +/// scalar loop only. +class ByteSetLookup +{ +public: + void add(char c) + { + auto byte = static_cast(c); + if (table[byte]) + return; + + table[byte] = true; + + UInt8 high = byte >> 4; + UInt8 low = byte & 0x0F; + + if (!high_nibble_bit[high]) + { + if (num_high_nibbles == 8) + { + vectorized = false; + return; + } + + high_nibble_bit[high] = static_cast(1u << num_high_nibbles); + ++num_high_nibbles; + } + + low_nibble_table[low] |= high_nibble_bit[high]; + high_nibble_table[high] = high_nibble_bit[high]; + } + + ALWAYS_INLINE bool contains(char c) const + { + return table[static_cast(c)]; + } + + /// Returns the first byte in [begin, end) whose membership in the set equals `positive`, or `end`. + template + const char * find(const char * begin, const char * end) const + { + const char * pos = begin; + + /// Most tokens are short, so the first bytes are checked one by one + /// before paying for a vector iteration that would mostly look past the token. + const char * scalar_end = end - pos > SCALAR_PREFIX ? pos + SCALAR_PREFIX : end; + for (; pos < scalar_end; ++pos) + { + if (contains(*pos) == positive) + return pos; + } + +#if defined(__SSSE3__) || defined(__aarch64__) + if (vectorized) + pos = findVectorized(pos, end); +#endif + + for (; pos < end; ++pos) + { + if (contains(*pos) == positive) + return pos; + } + + return end; + } + +private: + static constexpr ptrdiff_t SCALAR_PREFIX = 16; + static constexpr ptrdiff_t VECTOR_SIZE = 16; + +#if defined(__SSSE3__) || defined(__aarch64__) + /// GCC/Clang vector extensions lower each lane operation to one SSE/NEON instruction. + /// Only the byte permutation has no portable spelling. + using UInt8x16 = UInt8 __attribute__((vector_size(16))); + using UInt64x2 = UInt64 __attribute__((vector_size(16))); + + /// Returns a vector with `table[indexes[i]]` in lane `i`; every index must be less than 16. + static ALWAYS_INLINE UInt8x16 lookupBytes(UInt8x16 table, UInt8x16 indexes) + { +#if defined(__SSSE3__) + return std::bit_cast(_mm_shuffle_epi8(std::bit_cast<__m128i>(table), std::bit_cast<__m128i>(indexes))); +#else + return std::bit_cast(vqtbl1q_u8(std::bit_cast(table), std::bit_cast(indexes))); +#endif + } + + /// Scans whole 16-byte blocks. Returns the position of the first byte matching the search, + /// or the position from which fewer than 16 bytes remain. + template + const char * findVectorized(const char * pos, const char * end) const + { + const auto low_table = std::bit_cast(low_nibble_table); + const auto high_table = std::bit_cast(high_nibble_table); + + for (; end - pos >= VECTOR_SIZE; pos += VECTOR_SIZE) + { + UInt8x16 bytes; + memcpy(&bytes, pos, VECTOR_SIZE); + + UInt8x16 low = lookupBytes(low_table, bytes & 0x0F); + UInt8x16 high = lookupBytes(high_table, bytes >> 4); + + /// 0xFF in the lanes of the bytes that match the search, 0x00 in the others. + auto match = (low & high) != UInt8x16{}; + if constexpr (!positive) + match = ~match; + + /// The first matching byte is the lowest non-zero byte, little-endian. + const auto halves = std::bit_cast(match); + if (halves[0]) + return pos + std::countr_zero(halves[0]) / 8; + if (halves[1]) + return pos + 8 + std::countr_zero(halves[1]) / 8; + } + + return pos; + } +#endif + + bool table[256]{}; + UInt8 low_nibble_table[16]{}; + UInt8 high_nibble_table[16]{}; + UInt8 high_nibble_bit[16]{}; + size_t num_high_nibbles = 0; + bool vectorized = true; +}; + +} diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index ae8e1b283997..8b2ead954945 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -12,19 +12,10 @@ #include #include #include +#include #include #include -#include -#include - -#if defined(__SSSE3__) -# include -#endif -#if defined(__aarch64__) -# include -#endif - namespace DB { @@ -47,148 +38,6 @@ namespace constexpr char ESCAPE_CHARACTER = '\\'; -#if defined(__SSSE3__) || defined(__aarch64__) - -/// GCC/Clang vector extensions lower each lane operation to one SSE/NEON instruction. -/// Only the byte permutation has no portable spelling. -using UInt8x16 = UInt8 __attribute__((vector_size(16))); -using UInt64x2 = UInt64 __attribute__((vector_size(16))); - -/// Returns a vector with `table[indexes[i]]` in lane `i`; every index must be less than 16. -inline UInt8x16 lookupBytes(UInt8x16 table, UInt8x16 indexes) -{ -#if defined(__SSSE3__) - return std::bit_cast(_mm_shuffle_epi8(std::bit_cast<__m128i>(table), std::bit_cast<__m128i>(indexes))); -#else - return std::bit_cast(vqtbl1q_u8(std::bit_cast(table), std::bit_cast(indexes))); -#endif -} - -#endif - -/// A set of bytes with a vectorized search for the first byte that is (or is not) in the set. -/// The lookup tables are built once per set, so a search has no setup cost. -class ByteSet -{ -public: - void add(char c) - { - auto byte = static_cast(c); - if (table[byte]) - return; - - table[byte] = true; - - /// Tables for the vectorized search, which can only look up - /// 16-entry tables and therefore classifies a byte by its two nibbles: - /// - /// - Every high nibble that occurs in the set gets its own bit (`high_nibble_bit`). - /// - `high_nibble_table[high]` is that bit, or 0 if no member has this high nibble. - /// - `low_nibble_table[low]` is the union of the bits of all high nibbles that are - /// combined with this low nibble in some member. - /// - A byte is in the set iff the two lookups share a bit: - /// `low_nibble_table[low] & high_nibble_table[high] != 0`. - /// - /// The bits fit in one byte, so at most 8 distinct high nibbles are supported. That is - /// enough for any set of ASCII characters; a set that needs more is searched by the - /// scalar loop only. - UInt8 high = byte >> 4; - UInt8 low = byte & 0x0F; - - if (!high_nibble_bit[high]) - { - if (num_high_nibbles == 8) - { - vectorized = false; - return; - } - - high_nibble_bit[high] = static_cast(1u << num_high_nibbles); - ++num_high_nibbles; - } - - low_nibble_table[low] |= high_nibble_bit[high]; - high_nibble_table[high] = high_nibble_bit[high]; - } - - ALWAYS_INLINE bool contains(char c) const - { - return table[static_cast(c)]; - } - - /// Returns the first byte in [begin, end) whose membership in the set equals `positive`, or `end`. - template - const char * find(const char * begin, const char * end) const - { - const char * pos = begin; - - /// Most keys and values are short, so the first bytes are checked one by one - /// before paying for a vector iteration that would mostly look past the token. - const char * scalar_end = end - pos > SCALAR_PREFIX ? pos + SCALAR_PREFIX : end; - for (; pos < scalar_end; ++pos) - { - if (contains(*pos) == positive) - return pos; - } - - if (vectorized) - pos = findVectorized(pos, end); - - for (; pos < end; ++pos) - { - if (contains(*pos) == positive) - return pos; - } - - return end; - } - -private: - static constexpr ptrdiff_t SCALAR_PREFIX = 16; - static constexpr ptrdiff_t VECTOR_SIZE = 16; - - /// Scans whole 16-byte blocks. Returns the position of the first byte matching the search, - /// or the position from which fewer than 16 bytes remain. - template - const char * findVectorized(const char * pos, const char * end) const - { -#if defined(__SSSE3__) || defined(__aarch64__) - const auto low_table = std::bit_cast(low_nibble_table); - const auto high_table = std::bit_cast(high_nibble_table); - - for (; end - pos >= VECTOR_SIZE; pos += VECTOR_SIZE) - { - UInt8x16 bytes; - memcpy(&bytes, pos, VECTOR_SIZE); - - UInt8x16 low = lookupBytes(low_table, bytes & 0x0F); - UInt8x16 high = lookupBytes(high_table, bytes >> 4); - - /// 0xFF in the lanes of the bytes that match the search, 0x00 in the others. - auto match = (low & high) != UInt8x16{}; - if constexpr (!positive) - match = ~match; - - /// The first matching byte is the lowest non-zero byte, little-endian. - const auto halves = std::bit_cast(match); - if (halves[0]) - return pos + std::countr_zero(halves[0]) / 8; - if (halves[1]) - return pos + 8 + std::countr_zero(halves[1]) / 8; - } -#endif - - return pos; - } - - bool table[256]{}; - UInt8 low_nibble_table[16]{}; - UInt8 high_nibble_table[16]{}; - UInt8 high_nibble_bit[16]{}; - size_t num_high_nibbles = 0; - bool vectorized = true; -}; - /// A key or a value being read. Without escape sequences it is a view into the input; /// after the first decoded escape sequence it is accumulated in `buffer`. class Token @@ -266,16 +115,16 @@ struct KeyValuePairExtractor::Impl Configuration configuration; /// Bytes skipped while waiting for a key: the delimiters, and the escape character with escaping. - ByteSet waiting_key_bytes; + ByteSetLookup waiting_key_bytes; /// Bytes that end an unquoted key: both delimiters, the quoting character /// unless the strategy is `ACCEPT`, and the escape character with escaping. - ByteSet key_stop_bytes; + ByteSetLookup key_stop_bytes; /// Bytes that end an unquoted value: same as for a key, except the key-value delimiter is a regular byte. - ByteSet value_stop_bytes; + ByteSetLookup value_stop_bytes; /// Bytes that end a quoted key or value: the quoting character, and the escape character with escaping. - ByteSet quoted_stop_bytes; + ByteSetLookup quoted_stop_bytes; /// Bytes that end a pair: the pair delimiters. - ByteSet pair_delimiters; + ByteSetLookup pair_delimiters; explicit Impl(const Configuration & configuration_) : configuration(configuration_) @@ -343,7 +192,7 @@ struct KeyValuePairExtractor::Impl /// Reads a token starting at `pos` until a byte from `stop_set` and moves `pos` past that byte /// (or to `end`). With escaping, escape sequences are decoded into the token. template - Stop readToken(const ByteSet & stop_bytes, Token & token, std::string_view & result, const char *& pos, const char * end) const + Stop readToken(const ByteSetLookup & stop_bytes, Token & token, std::string_view & result, const char *& pos, const char * end) const { token.start(pos); From 72e72eaf60a1f62cda15209a855c4f4de5c9048a Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Fri, 11 Sep 2026 09:23:32 +0000 Subject: [PATCH 078/272] Trim the performance test of `extractKeyValuePairs` to 10 queries CI runs at most 10 randomly chosen queries per performance test, so with 18 queries each architecture measured a different subset. Drop the datasets and strategy variants that add little information (2 KB values, rows without pairs, `INVALID` and `ACCEPT`), leaving 5 datasets times 2 functions. Co-Authored-By: Claude Fable 5.1 --- tests/performance/extract_key_value_pairs.xml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/performance/extract_key_value_pairs.xml b/tests/performance/extract_key_value_pairs.xml index e531cd9bb7ff..ed9165312835 100644 --- a/tests/performance/extract_key_value_pairs.xml +++ b/tests/performance/extract_key_value_pairs.xml @@ -17,9 +17,6 @@ CREATE TABLE kv_long_values (s String) ENGINE = Memory INSERT INTO kv_long_values SELECT concat('k1:', repeat('a', 200), ' k2:', repeat('b', 200), ' k3:', toString(number)) FROM numbers(300000) - - CREATE TABLE kv_very_long_values (s String) ENGINE = Memory - INSERT INTO kv_very_long_values SELECT concat('k1:', repeat('a', 2000), ' k2:"', repeat('b', 2000), '" k3:', toString(number)) FROM numbers(30000) CREATE TABLE kv_log_lines (s String) ENGINE = Memory @@ -29,9 +26,6 @@ CREATE TABLE kv_many_tiny_pairs (s String) ENGINE = Memory INSERT INTO kv_many_tiny_pairs SELECT arrayStringConcat(arrayMap(i -> concat('k', toString(i), ':', toString((number + i) % 10)), range(25)), ' ') FROM numbers(500000) - - CREATE TABLE kv_no_pairs (s String) ENGINE = Memory - INSERT INTO kv_no_pairs SELECT repeat(char(97 + number % 26), 1000) FROM numbers(100000) CREATE TABLE kv_escaped (s String) ENGINE = Memory @@ -39,19 +33,13 @@ SELECT count() FROM kv_short WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 SELECT count() FROM kv_long_values WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 - SELECT count() FROM kv_very_long_values WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 SELECT count() FROM kv_log_lines WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 SELECT count() FROM kv_many_tiny_pairs WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 - SELECT count() FROM kv_no_pairs WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 SELECT count() FROM kv_escaped WHERE NOT ignore({func}(s)) SETTINGS max_threads = 1 - SELECT count() FROM kv_short WHERE NOT ignore({func}(s, ':', ' ,;', '"', 'INVALID')) SETTINGS max_threads = 1 - SELECT count() FROM kv_short WHERE NOT ignore({func}(s, ':', ' ,;', '"', 'ACCEPT')) SETTINGS max_threads = 1 DROP TABLE IF EXISTS kv_short DROP TABLE IF EXISTS kv_long_values - DROP TABLE IF EXISTS kv_very_long_values DROP TABLE IF EXISTS kv_log_lines DROP TABLE IF EXISTS kv_many_tiny_pairs - DROP TABLE IF EXISTS kv_no_pairs DROP TABLE IF EXISTS kv_escaped From 2ddc7215cb582480ddb1e8bf243fb9038e43e48b Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Fri, 11 Sep 2026 09:33:00 +0000 Subject: [PATCH 079/272] Reserve the output columns of `extractKeyValuePairs` from a sample of rows The keys and values columns grew by doubling while a block was processed, and the copies made on every regrowth took 15-17% of the query time in the profile. After 1/32 of the rows of a block are processed, the sizes of the output are extrapolated and reserved once. Query time on the `kv_log_lines` dataset of the performance test goes from 145 to 119 ms; the other datasets gain 5-33%. Co-Authored-By: Claude Fable 5.1 --- src/Functions/extractKeyValuePairs.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index 6a1bd83e8153..c7d93ee2c77b 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -474,11 +474,23 @@ public: auto & offsets_data = offsets->getData(); offsets_data.reserve(input_rows_count); + /// The output size is roughly proportional to the number of rows. After a sample of rows + /// it is extrapolated and reserved at once, so the columns do not regrow while they are filled. + const size_t sample_rows = std::max(1, input_rows_count / 32); + UInt64 offset = 0; for (size_t i = 0; i < input_rows_count; ++i) { offset += extractor.extract(data_column.getDataAt(i), *keys, *values); offsets_data.push_back(offset); + + if (i + 1 == sample_rows) + { + keys->getChars().reserve(keys->getChars().size() * input_rows_count / sample_rows); + values->getChars().reserve(values->getChars().size() * input_rows_count / sample_rows); + keys->getOffsets().reserve(offset * input_rows_count / sample_rows); + values->getOffsets().reserve(offset * input_rows_count / sample_rows); + } } return ColumnMap::create(ColumnPtr(std::move(keys)), ColumnPtr(std::move(values)), ColumnPtr(std::move(offsets))); From 1d3918dd30246bbfb01ab7ac8a60a85508924506 Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Fri, 11 Sep 2026 10:21:38 +0000 Subject: [PATCH 080/272] Cap the reserved output of `extractKeyValuePairs` by the size of the input The sizes extrapolated from the first rows of a block are now limited by what the output can reach at most: the keys and values together are never longer than the input, and a pair takes at least two bytes of it. A block whose first row is large and whose other rows are empty no longer reserves many times its result. The loop is split into the sampled rows, the reservation, and the remaining rows. Co-Authored-By: Claude Fable 5.1 --- src/Functions/extractKeyValuePairs.cpp | 38 ++++++++++++++++++-------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/Functions/extractKeyValuePairs.cpp b/src/Functions/extractKeyValuePairs.cpp index c7d93ee2c77b..7e9d00fa4b66 100644 --- a/src/Functions/extractKeyValuePairs.cpp +++ b/src/Functions/extractKeyValuePairs.cpp @@ -474,25 +474,39 @@ public: auto & offsets_data = offsets->getData(); offsets_data.reserve(input_rows_count); - /// The output size is roughly proportional to the number of rows. After a sample of rows - /// it is extrapolated and reserved at once, so the columns do not regrow while they are filled. - const size_t sample_rows = std::max(1, input_rows_count / 32); - UInt64 offset = 0; - for (size_t i = 0; i < input_rows_count; ++i) + auto process_row = [&](size_t i) { offset += extractor.extract(data_column.getDataAt(i), *keys, *values); offsets_data.push_back(offset); + }; - if (i + 1 == sample_rows) - { - keys->getChars().reserve(keys->getChars().size() * input_rows_count / sample_rows); - values->getChars().reserve(values->getChars().size() * input_rows_count / sample_rows); - keys->getOffsets().reserve(offset * input_rows_count / sample_rows); - values->getOffsets().reserve(offset * input_rows_count / sample_rows); - } + /// The output size is roughly proportional to the number of rows. It is extrapolated from a + /// sample of rows and reserved once, so the columns do not regrow while they are filled. + const size_t sample_rows = std::min(input_rows_count, std::max(1, input_rows_count / 32)); + for (size_t i = 0; i < sample_rows; ++i) + process_row(i); + + /// The sample may not be representative, so the estimate is capped by what the output can + /// reach at most: the keys and values together are never longer than the input, and a pair + /// takes at least two bytes of it. + const size_t input_bytes = data_column.byteSize(); + auto estimate = [&](size_t sample_size, size_t max_size) + { + return std::min(sample_size * input_rows_count / sample_rows, max_size); + }; + + if (sample_rows < input_rows_count) + { + keys->getChars().reserve(estimate(keys->getChars().size(), input_bytes)); + values->getChars().reserve(estimate(values->getChars().size(), input_bytes)); + keys->getOffsets().reserve(estimate(offset, input_bytes / 2)); + values->getOffsets().reserve(estimate(offset, input_bytes / 2)); } + for (size_t i = sample_rows; i < input_rows_count; ++i) + process_row(i); + return ColumnMap::create(ColumnPtr(std::move(keys)), ColumnPtr(std::move(values)), ColumnPtr(std::move(offsets))); } From cb21e1eaa97ed1531493fda7c1208070e729b7c1 Mon Sep 17 00:00:00 2001 From: Anton Popov Date: Fri, 11 Sep 2026 11:18:34 +0000 Subject: [PATCH 081/272] Include `` in `ByteSetLookup.h` The header uses `ptrdiff_t` and `size_t` and relied on its includers for the typedefs. Co-Authored-By: Claude Fable 5.1 --- src/Common/ByteSetLookup.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Common/ByteSetLookup.h b/src/Common/ByteSetLookup.h index 69dd89f2eda5..30fda26e8de4 100644 --- a/src/Common/ByteSetLookup.h +++ b/src/Common/ByteSetLookup.h @@ -4,6 +4,7 @@ #include #include +#include #include #if defined(__SSSE3__) From 8dcf41d1db99ecd1c4a3e9495f8ff3865258be65 Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:35:41 +0000 Subject: [PATCH 082/272] Revert "Fix loongarch64 build of `liburing` 2.15" The loongarch64 sysroot update in #119049 (Debian sid, kernel headers 7.1) ships linux/filter.h and linux/bpf_common.h, so the compat copies are not needed; the loongarch64 build of this PR depends on #119049 being merged first. --- contrib/liburing-cmake/CMakeLists.txt | 8 -- .../linux/bpf_common.h | 56 ------------ .../include-compat-loongarch64/linux/filter.h | 90 ------------------- 3 files changed, 154 deletions(-) delete mode 100644 contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h delete mode 100644 contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h diff --git a/contrib/liburing-cmake/CMakeLists.txt b/contrib/liburing-cmake/CMakeLists.txt index 9f35c2385e63..f54ae29ff860 100644 --- a/contrib/liburing-cmake/CMakeLists.txt +++ b/contrib/liburing-cmake/CMakeLists.txt @@ -28,14 +28,6 @@ add_compile_definitions (LIBURING_INTERNAL) set (LIBURING_COMPAT_INCLUDE_DIR "${ClickHouse_BINARY_DIR}/contrib/liburing/src/include-compat") set (LIBURING_COMPAT_HEADER "${LIBURING_COMPAT_INCLUDE_DIR}/liburing/compat.h") -# Since 2.15, `liburing.h` includes `` for the io_uring BPF filter API. -# The loongarch64 sysroot ships only a subset of the Linux UAPI headers and lacks -# `linux/filter.h` and `linux/bpf_common.h` (included by the former), so provide -# verbatim copies of them for that target. -if (ARCH_LOONGARCH64) - list (APPEND LIBURING_COMPAT_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include-compat-loongarch64") -endif () - set (LIBURING_CONFIG_HAS_KERNEL_RWF_T FALSE) set (LIBURING_CONFIG_HAS_KERNEL_TIMESPEC FALSE) set (LIBURING_CONFIG_HAS_OPEN_HOW FALSE) diff --git a/contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h b/contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h deleted file mode 100644 index 9f0201beb6c4..000000000000 --- a/contrib/liburing-cmake/include-compat-loongarch64/linux/bpf_common.h +++ /dev/null @@ -1,56 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */ -#ifndef __LINUX_BPF_COMMON_H__ -#define __LINUX_BPF_COMMON_H__ - -/* Instruction classes */ -#define BPF_CLASS(code) ((code) & 0x07) -#define BPF_LD 0x00 -#define BPF_LDX 0x01 -#define BPF_ST 0x02 -#define BPF_STX 0x03 -#define BPF_ALU 0x04 -#define BPF_JMP 0x05 -#define BPF_RET 0x06 -#define BPF_MISC 0x07 - -/* ld/ldx fields */ -#define BPF_SIZE(code) ((code) & 0x18) -#define BPF_W 0x00 -#define BPF_H 0x08 -#define BPF_B 0x10 -#define BPF_MODE(code) ((code) & 0xe0) -#define BPF_IMM 0x00 -#define BPF_ABS 0x20 -#define BPF_IND 0x40 -#define BPF_MEM 0x60 -#define BPF_LEN 0x80 -#define BPF_MSH 0xa0 - -/* alu/jmp fields */ -#define BPF_OP(code) ((code) & 0xf0) -#define BPF_ADD 0x00 -#define BPF_SUB 0x10 -#define BPF_MUL 0x20 -#define BPF_DIV 0x30 -#define BPF_OR 0x40 -#define BPF_AND 0x50 -#define BPF_LSH 0x60 -#define BPF_RSH 0x70 -#define BPF_NEG 0x80 -#define BPF_MOD 0x90 -#define BPF_XOR 0xa0 - -#define BPF_JA 0x00 -#define BPF_JEQ 0x10 -#define BPF_JGT 0x20 -#define BPF_JGE 0x30 -#define BPF_JSET 0x40 -#define BPF_SRC(code) ((code) & 0x08) -#define BPF_K 0x00 -#define BPF_X 0x08 - -#ifndef BPF_MAXINSNS -#define BPF_MAXINSNS 4096 -#endif - -#endif /* __LINUX_BPF_COMMON_H__ */ diff --git a/contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h b/contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h deleted file mode 100644 index eaef459e7bd4..000000000000 --- a/contrib/liburing-cmake/include-compat-loongarch64/linux/filter.h +++ /dev/null @@ -1,90 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */ -/* - * Linux Socket Filter Data Structures - */ - -#ifndef __LINUX_FILTER_H__ -#define __LINUX_FILTER_H__ - - -#include -#include - -/* - * Current version of the filter code architecture. - */ -#define BPF_MAJOR_VERSION 1 -#define BPF_MINOR_VERSION 1 - -/* - * Try and keep these values and structures similar to BSD, especially - * the BPF code definitions which need to match so you can share filters - */ - -struct sock_filter { /* Filter block */ - __u16 code; /* Actual filter code */ - __u8 jt; /* Jump true */ - __u8 jf; /* Jump false */ - __u32 k; /* Generic multiuse field */ -}; - -struct sock_fprog { /* Required for SO_ATTACH_FILTER. */ - unsigned short len; /* Number of filter blocks */ - struct sock_filter *filter; -}; - -/* ret - BPF_K and BPF_X also apply */ -#define BPF_RVAL(code) ((code) & 0x18) -#define BPF_A 0x10 - -/* misc */ -#define BPF_MISCOP(code) ((code) & 0xf8) -#define BPF_TAX 0x00 -#define BPF_TXA 0x80 - -/* - * Macros for filter block array initializers. - */ -#ifndef BPF_STMT -#define BPF_STMT(code, k) { (unsigned short)(code), 0, 0, k } -#endif -#ifndef BPF_JUMP -#define BPF_JUMP(code, k, jt, jf) { (unsigned short)(code), jt, jf, k } -#endif - -/* - * Number of scratch memory words for: BPF_ST and BPF_STX - */ -#define BPF_MEMWORDS 16 - -/* RATIONALE. Negative offsets are invalid in BPF. - We use them to reference ancillary data. - Unlike introduction new instructions, it does not break - existing compilers/optimizers. - */ -#define SKF_AD_OFF (-0x1000) -#define SKF_AD_PROTOCOL 0 -#define SKF_AD_PKTTYPE 4 -#define SKF_AD_IFINDEX 8 -#define SKF_AD_NLATTR 12 -#define SKF_AD_NLATTR_NEST 16 -#define SKF_AD_MARK 20 -#define SKF_AD_QUEUE 24 -#define SKF_AD_HATYPE 28 -#define SKF_AD_RXHASH 32 -#define SKF_AD_CPU 36 -#define SKF_AD_ALU_XOR_X 40 -#define SKF_AD_VLAN_TAG 44 -#define SKF_AD_VLAN_TAG_PRESENT 48 -#define SKF_AD_PAY_OFFSET 52 -#define SKF_AD_RANDOM 56 -#define SKF_AD_VLAN_TPID 60 -#define SKF_AD_MAX 64 - -#define SKF_NET_OFF (-0x100000) -#define SKF_LL_OFF (-0x200000) - -#define BPF_NET_OFF SKF_NET_OFF -#define BPF_LL_OFF SKF_LL_OFF - -#endif /* __LINUX_FILTER_H__ */ From da41a81ceaf4f1e2f6e9b0fcf9d0c44fb6312780 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 11 Sep 2026 23:19:27 +0000 Subject: [PATCH 083/272] Attribute fully qualified `db.table.*` matchers to the skipped join side A qualified matcher is resolved by looking up its qualifier as a table expression, and for a fully qualified `db.table.*` that qualifier is the two-part identifier `db.table`. The skip path in `tryResolveIdentifierFromJoin` only consulted the `database_qualified` binding of `qualifierBindsToJoinSubtree` when the identifier had more than two parts, which is right for an expression lookup (`db.table.column`) but one part too many for a table expression lookup. As a result `db.table.*` against the non-preserved side of a `SEMI` / `ANTI` join never set `denied_qualified_access`: the live spelling degraded to `UNKNOWN_IDENTIFIER` ("does not find table") instead of the dedicated denial, and inside a statically dead `if(false, ...)` branch that error was swallowed and the branch folded away, so the access violation went unreported entirely. Require the `database_qualified` fallback from two parts for a table expression lookup and from three for an expression lookup, and add regression cases for both the live and the dead-branch spelling of `db.table.*`, for `SEMI` and `ANTI`, plus preserved-side controls. Co-Authored-By: Claude Opus 5 (1M context) --- src/Analyzer/Resolve/IdentifierResolver.cpp | 14 +++++++++----- .../03913_semi_anti_join_column_access.sql | 11 +++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index b770afa8aa57..5549f46ae654 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1532,12 +1532,16 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I const bool is_table_lookup = identifier_lookup.isTableExpressionLookup(); const bool is_qualified_expr = identifier_lookup.isExpressionLookup() && identifier_lookup.identifier.getPartsSize() > 1; - /// A fully qualified `db.table.column` reference names the skipped table just as well as - /// an alias or a bare table name does, so it must be attributed to the skipped side too. - /// Otherwise the reference degrades to `UNKNOWN_IDENTIFIER`, which a statically-dead - /// `if(false, ...)` branch silently folds away instead of reporting the access violation. + /// A fully qualified reference names the skipped table just as well as an alias or a bare + /// table name does, so it must be attributed to the skipped side too. Otherwise the + /// reference degrades to `UNKNOWN_IDENTIFIER`, which a statically-dead `if(false, ...)` + /// branch silently folds away instead of reporting the access violation. + /// The `db.table` prefix takes two parts, so it is the whole identifier of a table + /// expression lookup (`db.table.*` is looked up by its qualifier `db.table`), while an + /// expression lookup needs a third part for the column name (`db.table.column`). + const size_t min_database_qualified_parts = is_table_lookup ? 2 : 3; const bool binds_qualifier = qualifierBindsToJoinSubtree(join_tree_node, identifier_lookup.identifier, scope, /*database_qualified=*/false) - || (identifier_lookup.identifier.getPartsSize() > 2 + || (identifier_lookup.identifier.getPartsSize() >= min_database_qualified_parts && qualifierBindsToJoinSubtree(join_tree_node, identifier_lookup.identifier, scope, /*database_qualified=*/true)); if ((is_table_lookup || is_qualified_expr) && binds_qualifier) denied_qualified_access = side; diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 52a698044673..2a00c3bed1df 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -316,6 +316,17 @@ SELECT if(false, semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.s SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l.a, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- Live branch referencing the fully qualified non-preserved side: also denied. SELECT if(true, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.b, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- A fully qualified matcher `db.table.*` is looked up as a table expression by its two-part +-- qualifier `db.table`, so it must be attributed to the skipped side by the database-qualified +-- binding as well - both in a live position and inside a statically dead branch. +SELECT {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.* FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.*, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- The same spellings against an ANTI JOIN. +SELECT {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.* FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT ANTI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON false; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r.*, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT ANTI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON false; -- { serverError SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED } +-- The fully qualified matcher for the preserved side keeps working. +SELECT {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l.* FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; +SELECT if(false, {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l.*, 42) FROM {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_l LEFT SEMI JOIN {CLICKHOUSE_DATABASE:Identifier}.semi_anti_qual_r ON true; -- A skipped-side table whose name equals the current database name must not deny a preserved-side -- fully qualified reference: with analyzer_compatibility_prefer_alias_over_subcolumn the first -- identifier part binds the skipped right side first, and the denial must wait until the From 8fa58ba7b929afa850add2d89e684b28f8e38308 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 12 Sep 2026 00:36:03 +0000 Subject: [PATCH 084/272] Add the reference lines for the preserved-side `db.table.*` controls The two new preserved-side queries return a row each: the live `db.semi_anti_qual_l.*` matcher yields the left row, and the dead `if(false, db.semi_anti_qual_l.*, 42)` branch folds to `42` as before. Verified against a control binary without the fix, which reports `Qualified matcher default.semi_anti_qual_r.* does not find table` (`UNKNOWN_IDENTIFIER`) for the denial cases instead of `SEMI_ANTI_JOIN_COLUMN_ACCESS_DENIED`. Co-Authored-By: Claude Opus 5 (1M context) --- .../0_stateless/03913_semi_anti_join_column_access.reference | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index bff9d4729aa5..d82df1a14195 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -39,4 +39,6 @@ 1 42 1 +42 +1 2 From 8b3593307f6ef27e2de618dc5946aa39ec6ee36e Mon Sep 17 00:00:00 2001 From: ClickGap Bot Date: Wed, 16 Sep 2026 10:26:44 +0000 Subject: [PATCH 085/272] Add 1 test for coverage gaps in PR #119830 --- ...m_projection_parts_unknown_codec.reference | 6 ++++ ...8_system_projection_parts_unknown_codec.sh | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.reference create mode 100755 tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh diff --git a/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.reference b/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.reference new file mode 100644 index 000000000000..76389db98681 --- /dev/null +++ b/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.reference @@ -0,0 +1,6 @@ +-- the codec of the projection part is known +p 0 ZSTD(3) +-- codec file gone: the codec recovered from the projection data is reported as unknown +p 0 UNKNOWN +-- projection part could not be loaded: broken, with no codec +p 1 1 diff --git a/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh b/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh new file mode 100755 index 000000000000..a93295290488 --- /dev/null +++ b/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# `system.projection_parts.default_compression_codec` must not present a guess as the codec of a +# projection part: a codec that could only be recovered from the projection data is reported as +# 'UNKNOWN', and a projection part that could not be loaded at all reports no codec. + +data_path="${CLICKHOUSE_TMP}/${CLICKHOUSE_TEST_UNIQUE_NAME}" +rm -rf "${data_path:?}" + +$CLICKHOUSE_LOCAL --path "$data_path" -m -q " +CREATE TABLE tab (a UInt64, b String, PROJECTION p (SELECT b ORDER BY b)) +ENGINE = MergeTree ORDER BY a +SETTINGS default_compression_codec = 'ZSTD(3)', min_bytes_for_wide_part = 0, min_rows_for_wide_part = 0; +INSERT INTO tab SELECT number, toString(number) FROM numbers(1000); +" + +part_path=$($CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT path FROM system.parts WHERE table = 'tab' AND active ORDER BY name") + +echo '-- the codec of the projection part is known' +$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, default_compression_codec FROM system.projection_parts WHERE table = 'tab' AND active ORDER BY name" + +rm "${part_path:?}p.proj/default_compression_codec.txt" + +echo '-- codec file gone: the codec recovered from the projection data is reported as unknown' +$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, default_compression_codec FROM system.projection_parts WHERE table = 'tab' AND active ORDER BY name" + +rm -rf "${part_path:?}p.proj" + +echo '-- projection part could not be loaded: broken, with no codec' +$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, empty(default_compression_codec) FROM system.projection_parts WHERE table = 'tab' AND active ORDER BY name" + +rm -rf "${data_path:?}" From a19b55d18fe6135cd7fcd2dd341fa6f8f2f2a54a Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Wed, 16 Sep 2026 11:26:54 +0000 Subject: [PATCH 086/272] Address review: guard every outbound read of the live password and make the passwordless-login bookkeeping best-effort `dropAutofilledStalePassword` now also runs before `loadDatabases` (opening the databases panel), before the download rerun, before `storeCredentials` and in `sendCredentialsToTerminal`, so a stale autofilled password for a login remembered as passwordless is dropped on every path that sends or stores it, not only on credential checks, query submission and completions. `rememberLoginPassword` and `loadPasswordlessLogins` swallow `localStorage` failures (`QuotaExceededError`, storage policy exceptions), so a successful authentication check or query is never turned into a client-side error by the bookkeeping. Co-Authored-By: Claude Fable 5.1 --- programs/server/play.html | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/programs/server/play.html b/programs/server/play.html index 06be37319c14..e37b558fbe54 100644 --- a/programs/server/play.html +++ b/programs/server/play.html @@ -9228,6 +9228,8 @@ function closeDatabases() { function toggleDatabases() { if (!databases_toggle.dataset['opened']) { + /// The databases list is a request with the live credentials (see `dropAutofilledStalePassword`). + dropAutofilledStalePassword(); loadDatabases(url_elem.value, user_elem.value, password_elem.value).then(ok => { if (ok !== false) { databases_toggle.dataset['opened'] = 1; @@ -9440,12 +9442,18 @@ function passwordlessLoginKey(server_address, user) { function loadPasswordlessLogins() { let parsed = null; - try { parsed = JSON.parse(window.localStorage.getItem(PASSWORDLESS_LOGINS_KEY) ?? '[]'); } catch (e) { /* malformed: start over */ } + /// Storage access can throw too (a storage policy denying it), not only the parse of a malformed value. + try { parsed = JSON.parse(window.localStorage.getItem(PASSWORDLESS_LOGINS_KEY) ?? '[]'); } catch (e) { /* malformed or inaccessible: start over */ } return new Set(Array.isArray(parsed) ? parsed.filter(k => typeof k === 'string') : []); } /// Record a successful authentication as `server_address`/`user` with `password`: an empty password /// remembers the login as passwordless, a non-empty one forgets it. +/// +/// Best-effort: it runs on the success path of an authentication check and of a query, and a storage +/// failure (`QuotaExceededError`, a storage policy exception) must not turn that success into a +/// client-side error or lose the green indication. Without the entry the page merely does not drop a +/// stale autofilled password next time, which is the pre-existing behaviour. function rememberLoginPassword(server_address, user, password) { const key = passwordlessLoginKey(server_address, user); const logins = loadPasswordlessLogins(); @@ -9457,7 +9465,7 @@ function rememberLoginPassword(server_address, user, password) { /// A `Set` iterates in insertion order, so the oldest entries go first. while (logins.size > MAX_PASSWORDLESS_LOGINS) logins.delete(logins.values().next().value); } - window.localStorage.setItem(PASSWORDLESS_LOGINS_KEY, JSON.stringify([...logins])); + try { window.localStorage.setItem(PASSWORDLESS_LOGINS_KEY, JSON.stringify([...logins])); } catch (e) { /* best-effort, see above */ } } /// Whether the browser filled the field itself (its password manager), as opposed to the user typing @@ -15131,6 +15139,8 @@ async function cancel() } function storeCredentials() { + /// A stale autofilled password must not be re-offered to the browser's store either. + dropAutofilledStalePassword(); if (password_elem.value) { const cred = new PasswordCredential({ id: user_elem.value, @@ -19281,6 +19291,7 @@ document.getElementById('download-button').addEventListener('click', async funct // connection then surfaces as a visible authentication error rather than silently exporting // data from the wrong server. const user = last_user_for_download; + dropAutofilledStalePassword(); const password = password_elem.value; const server_address = last_url_for_download || url_elem.value; const query = last_query_for_download; @@ -19427,6 +19438,8 @@ document.getElementById('download-button').addEventListener('click', async funct function sendCredentialsToTerminal() { if (terminalIframe && terminalIframe.contentWindow && terminalOrigin) { + /// The terminal authenticates with these credentials (see `dropAutofilledStalePassword`). + dropAutofilledStalePassword(); terminalIframe.contentWindow.postMessage({ type: 'webterminal-credentials', user: user_elem.value, From 8ef393b09ab053fb0d0ef9f46e8275b883e8f678 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Wed, 16 Sep 2026 11:28:42 +0000 Subject: [PATCH 087/272] Web UI: do not require the `CSS` global at page-script load time The `:autofill` selector detection called `CSS.supports` while the page script was being evaluated. The headless harnesses of `test_play_image_preview` and `test_play_reconcile_startup` run the extracted script in a Node `vm` context without a `CSS` global, so the whole script failed with `ReferenceError: CSS is not defined`. The detection now yields no selector where `CSS.supports` is unavailable, which leaves the stale-password handling inert there, as it already is in a browser supporting neither form. CI report: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=118638&sha=5e5b5018080d4bf074737d61603ce90eaa0df59c&name_0=PR&name_1=Integration%20tests%20%28amd_asan_ubsan%2C%20db%20disk%2C%20old%20analyzer%2C%201%2F6%29 PR: https://github.com/ClickHouse/ClickHouse/pull/118638 Co-Authored-By: Claude Fable 5.1 --- programs/server/play.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/programs/server/play.html b/programs/server/play.html index e37b558fbe54..b834a025aa3e 100644 --- a/programs/server/play.html +++ b/programs/server/play.html @@ -9470,9 +9470,12 @@ function rememberLoginPassword(server_address, user, password) { /// Whether the browser filled the field itself (its password manager), as opposed to the user typing /// or the page setting it. Reported by the standard `:autofill` pseudo-class, or its `-webkit-` form in -/// older engines; a browser supporting neither never reports a field as autofilled, which leaves the +/// older engines; a browser supporting neither (or an environment without `CSS.supports`, such as the +/// headless harnesses of the integration tests) never reports a field as autofilled, which leaves the /// stale-password handling inert there. -const AUTOFILL_SELECTOR = [':autofill', ':-webkit-autofill'].find(s => CSS.supports(`selector(${s})`)) ?? null; +const AUTOFILL_SELECTOR = (typeof CSS !== 'undefined' && typeof CSS.supports === 'function') + ? ([':autofill', ':-webkit-autofill'].find(s => CSS.supports(`selector(${s})`)) ?? null) + : null; function isAutofilled(elem) { return AUTOFILL_SELECTOR !== null && elem.matches(AUTOFILL_SELECTOR); } From 28d148d97f884adb3b9e65e6f0877b485a954ddd Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Thu, 17 Sep 2026 11:25:39 +0000 Subject: [PATCH 088/272] Keep the state version in columns made by `+`, `*` and `CAST` of aggregate states Address review: the columns manufactured by `executeAggregateAddition`, `executeAggregateMultiply` and `CAST AS AggregateFunction` were created without a state version, so they serialized their states at the function's default version (`0` for `uniq`) while their declared type spelled out version `1`. The first round trip through an arena (`groupArray`, sorting) then read the states back in the wrong layout: `groupArray(uniqState(x) + uniqState(y))` failed with `INCORRECT_DATA` (extra bytes), and the wide 64-bit sample would be lost where the lengths happened to agree. The fresh columns now take the version of the result type. In addition, `DataTypeAggregateFunction::strictEquals` now compares the state version, so that `CAST(state AS AggregateFunction(uniq, ...))` from a version `1` state is no longer elided as an identity conversion: the states are shared as they are, but the column switches to the layout of the target type. `equals` is unaffected, because it compares normalized state types, which carry no explicit version. Test `05223_uniq_state_version_arithmetic_and_cast` round-trips the results of `+`, `*` and `CAST` (to both versions) for `uniqState` and `quantileDeterministicState` through `groupArray` and `remote()`. Co-Authored-By: Claude Fable 5.1 --- src/DataTypes/DataTypeAggregateFunction.cpp | 5 ++++ src/DataTypes/DataTypeAggregateFunction.h | 2 +- src/Functions/FunctionBinaryArithmetic.h | 22 +++++++++++++----- src/Functions/FunctionsConversion.cpp | 11 +++++---- ...tate_version_arithmetic_and_cast.reference | 17 ++++++++++++++ ...uniq_state_version_arithmetic_and_cast.sql | 23 +++++++++++++++++++ 6 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.reference create mode 100644 tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.sql diff --git a/src/DataTypes/DataTypeAggregateFunction.cpp b/src/DataTypes/DataTypeAggregateFunction.cpp index 3908507c37ec..c1f4ac001540 100644 --- a/src/DataTypes/DataTypeAggregateFunction.cpp +++ b/src/DataTypes/DataTypeAggregateFunction.cpp @@ -180,6 +180,11 @@ bool DataTypeAggregateFunction::strictEquals(const DataTypePtr & lhs_state_type, if (lhs_state->function->getName() != rhs_state->function->getName()) return false; + /// Different versions of the same state (e.g. `AggregateFunction(uniq, ...)` and `AggregateFunction(1, uniq, ...)`) + /// are serialized differently, so a column cannot pass from one type to the other as it is. + if (lhs_state->getVersion() != rhs_state->getVersion()) + return false; + if (lhs_state->parameters.size() != rhs_state->parameters.size()) return false; diff --git a/src/DataTypes/DataTypeAggregateFunction.h b/src/DataTypes/DataTypeAggregateFunction.h index b4c22dfe171c..758e81717320 100644 --- a/src/DataTypes/DataTypeAggregateFunction.h +++ b/src/DataTypes/DataTypeAggregateFunction.h @@ -58,7 +58,7 @@ public: Field getDefault() const override; - /// Compares name, parameters, and argument types. + /// Compares name, state version, parameters, and argument types. /// When ignore_variant is false (default), also compares the state variant (Aggregation vs Window). static bool strictEquals(const DataTypePtr & lhs_state_type, const DataTypePtr & rhs_state_type, bool ignore_variant = false); diff --git a/src/Functions/FunctionBinaryArithmetic.h b/src/Functions/FunctionBinaryArithmetic.h index 3802be536bfe..83b2911778f1 100644 --- a/src/Functions/FunctionBinaryArithmetic.h +++ b/src/Functions/FunctionBinaryArithmetic.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -1271,7 +1272,16 @@ class FunctionBinaryArithmetic : public IFunction, WithContext } /// Multiply aggregation state by integer constant: by merging it with itself specified number of times. - ColumnPtr executeAggregateMultiply(const ColumnsWithTypeAndName & arguments, const DataTypePtr &, size_t input_rows_count) const + /// A column of aggregate states made by hand must carry the state version of its declared type + /// (e.g. `AggregateFunction(1, uniq, UInt64)`): the version affects how the states are serialized + /// on a later round trip through an arena (`groupArray`, sorting) - a fresh column would use the + /// function's default version instead and the result would not match its type. + static std::optional getAggregateStateVersion(const DataTypePtr & result_type) + { + return assert_cast(*result_type).getVersionIfExplicit(); + } + + ColumnPtr executeAggregateMultiply(const ColumnsWithTypeAndName & arguments, const DataTypePtr & result_type, size_t input_rows_count) const { ColumnsWithTypeAndName new_arguments = arguments; if (WhichDataType(new_arguments[1].type).isAggregateFunction()) @@ -1290,10 +1300,10 @@ class FunctionBinaryArithmetic : public IFunction, WithContext size_t size = agg_state_is_const ? 1 : input_rows_count; - auto column_to = ColumnAggregateFunction::create(function); + auto column_to = ColumnAggregateFunction::create(function, getAggregateStateVersion(result_type)); column_to->reserve(size); - auto column_from = ColumnAggregateFunction::create(function); + auto column_from = ColumnAggregateFunction::create(function, getAggregateStateVersion(result_type)); column_from->reserve(size); for (size_t i = 0; i < size; ++i) @@ -1340,7 +1350,7 @@ class FunctionBinaryArithmetic : public IFunction, WithContext } else { - auto column_temp = ColumnAggregateFunction::create(function); + auto column_temp = ColumnAggregateFunction::create(function, getAggregateStateVersion(result_type)); column_temp->reserve(size); for (size_t i = 0; i < size; ++i) column_temp->insertFrom(vec_from[i]); @@ -1359,7 +1369,7 @@ class FunctionBinaryArithmetic : public IFunction, WithContext } /// Merge two aggregation states together. - ColumnPtr executeAggregateAddition(const ColumnsWithTypeAndName & arguments, const DataTypePtr &, size_t input_rows_count) const + ColumnPtr executeAggregateAddition(const ColumnsWithTypeAndName & arguments, const DataTypePtr & result_type, size_t input_rows_count) const { const IColumn & lhs_column = *arguments[0].column; const IColumn & rhs_column = *arguments[1].column; @@ -1376,7 +1386,7 @@ class FunctionBinaryArithmetic : public IFunction, WithContext size_t size = (lhs_is_const && rhs_is_const) ? 1 : input_rows_count; - auto column_to = ColumnAggregateFunction::create(function); + auto column_to = ColumnAggregateFunction::create(function, getAggregateStateVersion(result_type)); column_to->reserve(size); for (size_t i = 0; i < size; ++i) diff --git a/src/Functions/FunctionsConversion.cpp b/src/Functions/FunctionsConversion.cpp index 16a22540988d..a9bb2bb7efd9 100644 --- a/src/Functions/FunctionsConversion.cpp +++ b/src/Functions/FunctionsConversion.cpp @@ -778,7 +778,10 @@ FunctionCast::WrapperType FunctionCast::createAggregateFunctionWrapper(const Dat { if (agg_type->getFunction()->haveSameStateRepresentation(*to_type->getFunction())) { - return [function = to_type->getFunction()]( + /// The states are shared as they are, but the result column must take the state version + /// of the target type (e.g. `AggregateFunction(1, uniq, UInt64)`), because the version + /// affects how the states are serialized on a later round trip through an arena. + return [function = to_type->getFunction(), version = to_type->getVersionIfExplicit()]( ColumnsWithTypeAndName & arguments, const DataTypePtr & /* result_type */, const ColumnNullable * /* nullable_source */, @@ -789,7 +792,7 @@ FunctionCast::WrapperType FunctionCast::createAggregateFunctionWrapper(const Dat if (col_agg) { auto new_col_agg = ColumnAggregateFunction::create(*col_agg); - new_col_agg->set(function); + new_col_agg->set(function, version); return new_col_agg; } else @@ -806,7 +809,7 @@ FunctionCast::WrapperType FunctionCast::createAggregateFunctionWrapper(const Dat /// can be converted by merging the source state into a fresh target state. if (to_type->getFunction()->canMergeStateFromDifferentVariant(*agg_type->getFunction())) { - return [function = to_type->getFunction(), from_function = agg_type->getFunction()]( + return [function = to_type->getFunction(), from_function = agg_type->getFunction(), version = to_type->getVersionIfExplicit()]( ColumnsWithTypeAndName & arguments, const DataTypePtr & /* result_type */, const ColumnNullable * /* nullable_source */, @@ -820,7 +823,7 @@ FunctionCast::WrapperType FunctionCast::createAggregateFunctionWrapper(const Dat "Illegal column {} for function CAST AS AggregateFunction", argument_column.column->getName()); - auto res = ColumnAggregateFunction::create(function); + auto res = ColumnAggregateFunction::create(function, version); for (const auto * src_state : col_agg->getData()) { diff --git a/tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.reference b/tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.reference new file mode 100644 index 000000000000..76a9fe8b11bb --- /dev/null +++ b/tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.reference @@ -0,0 +1,17 @@ +plus AggregateFunction(1, uniq, UInt64) 01000F000000007518F0A82CCBC23487C6A393D5F1F4F7C472D5A1E7830665CED8F809DDD168E40DD3482535B0A84475554CD573457CC745313DD81C774C0A12010000000000000000 +plus via groupArray 01000F000000007518F0A82CCBC23487C6A393D5F1F4F7C472D5A1E7830665CED8F809DDD168E40DD3482535B0A84475554CD573457CC745313DD81C774C0A12010000000000000000 +plus via remote 15 +plus via remote 15 +multiply AggregateFunction(1, uniq, UInt64) 01000A000000007518F0A82CCBC23487C6A393D5F1F4F7E7830665CED8F809DDD168E475554CD573457CC712010000000000000000 +multiply via groupArray 01000A000000007518F0A82CCBC23487C6A393D5F1F4F7E7830665CED8F809DDD168E475554CD573457CC712010000000000000000 +multiply via remote 10 +multiply via remote 10 +cast v1 AggregateFunction(1, uniq, UInt64) 01000A000000007518F0A82CCBC23487C6A393D5F1F4F7E7830665CED8F809DDD168E475554CD573457CC712010000000000000000 +cast v1 via groupArray 01000A000000007518F0A82CCBC23487C6A393D5F1F4F7E7830665CED8F809DDD168E475554CD573457CC712010000000000000000 +cast v0 AggregateFunction(uniq, UInt64) 000A000000007518F0A82CCBC23487C6A393D5F1F4F7E7830665CED8F809DDD168E475554CD573457CC7 +cast v0 via groupArray 000A000000007518F0A82CCBC23487C6A393D5F1F4F7E7830665CED8F809DDD168E475554CD573457CC7 +cast v0 via remote 10 +cast v0 via remote 10 +quantileDeterministic plus via groupArray AggregateFunction(1, quantileDeterministic, UInt64, UInt64) 54.5 +quantileDeterministic multiply via groupArray 4.5 +quantileDeterministic cast v0 via groupArray AggregateFunction(quantileDeterministic, UInt64, UInt64) 4.5 diff --git a/tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.sql b/tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.sql new file mode 100644 index 000000000000..ea853db6d6f2 --- /dev/null +++ b/tests/queries/0_stateless/05223_uniq_state_version_arithmetic_and_cast.sql @@ -0,0 +1,23 @@ +-- Columns of aggregate states produced by `+`, `*` and `CAST` must keep the state version of their type +-- (`AggregateFunction(1, uniq, ...)`), so that a later round trip through an arena (`groupArray`) +-- or through the wire (`remote`) reads the states back in the layout they were written in. + +SELECT 'plus', toTypeName(s), hex(s) FROM (SELECT uniqState(number) + uniqState(number + 5) AS s FROM numbers(10)); +SELECT 'plus via groupArray', hex(arrayJoin(groupArray(s))) FROM (SELECT uniqState(number) + uniqState(number + 5) AS s FROM numbers(10)); +SELECT 'plus via remote', finalizeAggregation(s) FROM remote('127.0.0.{1,2}', view(SELECT uniqState(number) + uniqState(number + 5) AS s FROM numbers(10))) ORDER BY hex(s); + +SELECT 'multiply', toTypeName(s), hex(s) FROM (SELECT uniqState(number) * 2 AS s FROM numbers(10)); +SELECT 'multiply via groupArray', hex(arrayJoin(groupArray(s))) FROM (SELECT uniqState(number) * 3 AS s FROM numbers(10)); +SELECT 'multiply via remote', finalizeAggregation(s) FROM remote('127.0.0.{1,2}', view(SELECT uniqState(number) * 2 AS s FROM numbers(10))) ORDER BY hex(s); + +-- CAST keeps the version of the target type: the same states, spelled out in version 1 or in the legacy version 0. +SELECT 'cast v1', toTypeName(s), hex(s) FROM (SELECT CAST(uniqState(number) AS AggregateFunction(1, uniq, UInt64)) AS s FROM numbers(10)); +SELECT 'cast v1 via groupArray', hex(arrayJoin(groupArray(s))) FROM (SELECT CAST(uniqState(number) AS AggregateFunction(1, uniq, UInt64)) AS s FROM numbers(10)); +SELECT 'cast v0', toTypeName(s), hex(s) FROM (SELECT CAST(uniqState(number) AS AggregateFunction(uniq, UInt64)) AS s FROM numbers(10)); +SELECT 'cast v0 via groupArray', hex(arrayJoin(groupArray(s))) FROM (SELECT CAST(uniqState(number) AS AggregateFunction(uniq, UInt64)) AS s FROM numbers(10)); +SELECT 'cast v0 via remote', finalizeAggregation(s) FROM remote('127.0.0.{1,2}', view(SELECT CAST(uniqState(number) AS AggregateFunction(uniq, UInt64)) AS s FROM numbers(10))) ORDER BY hex(s); + +-- `quantileDeterministic` is the other function whose state type spells out a version. +SELECT 'quantileDeterministic plus via groupArray', toTypeName(s), finalizeAggregation(arrayJoin(groupArray(s))) FROM (SELECT quantileDeterministicState(number, number) + quantileDeterministicState(number + 100, number) AS s FROM numbers(10)) GROUP BY toTypeName(s); +SELECT 'quantileDeterministic multiply via groupArray', finalizeAggregation(arrayJoin(groupArray(s))) FROM (SELECT quantileDeterministicState(number, number) * 2 AS s FROM numbers(10)); +SELECT 'quantileDeterministic cast v0 via groupArray', toTypeName(s), finalizeAggregation(arrayJoin(groupArray(s))) FROM (SELECT CAST(quantileDeterministicState(number, number) AS AggregateFunction(quantileDeterministic, UInt64, UInt64)) AS s FROM numbers(10)) GROUP BY toTypeName(s); From 4bfb71c6b86fb4b16d230b562a02f1bcd951ddb0 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 18 Sep 2026 01:45:58 +0000 Subject: [PATCH 089/272] Address review: reject a column matcher in a filter instead of resolving it Per novikd's review: a matcher (`*`, `t.*`, `COLUMNS(...)`) has no useful application in a row policy, `additional_table_filters` or `additional_result_filter`, so forbid it deliberately rather than teach matcher resolution about a scope without a query. The analyzer changes are reverted; `buildFilterQueryTree` now walks the filter's query tree before analysis and throws `BAD_ARGUMENTS` with a clear diagnostic on any `MatcherNode`, without descending into subqueries, where `SELECT * FROM allowed` keeps resolving against its own table. The walk also covers a matcher inside a lambda body, which the previous approach missed. Test `05194_matcher_in_additional_filters` is replaced by `05227_matcher_in_filter_is_rejected`, and the accepted error codes of `03571_trying_to_get_name_of_not_a_column_asterisk` are back to master's. Co-Authored-By: Claude Fable 5.1 --- src/Analyzer/Resolve/IdentifierResolveScope.h | 8 ---- src/Analyzer/Resolve/QueryAnalyzer.cpp | 34 +++------------- src/Planner/Utils.cpp | 22 ++++++++++ ...g_to_get_name_of_not_a_column_asterisk.sql | 2 +- ...94_matcher_in_additional_filters.reference | 16 -------- .../05194_matcher_in_additional_filters.sql | 40 ------------------- ...27_matcher_in_filter_is_rejected.reference | 6 +++ .../05227_matcher_in_filter_is_rejected.sql | 37 +++++++++++++++++ 8 files changed, 71 insertions(+), 94 deletions(-) delete mode 100644 tests/queries/0_stateless/05194_matcher_in_additional_filters.reference delete mode 100644 tests/queries/0_stateless/05194_matcher_in_additional_filters.sql create mode 100644 tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.reference create mode 100644 tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.sql diff --git a/src/Analyzer/Resolve/IdentifierResolveScope.h b/src/Analyzer/Resolve/IdentifierResolveScope.h index fba47738a2e4..5c1aa4cb21df 100644 --- a/src/Analyzer/Resolve/IdentifierResolveScope.h +++ b/src/Analyzer/Resolve/IdentifierResolveScope.h @@ -235,14 +235,6 @@ struct IdentifierResolveScope */ TableExpressionNodePtr expression_join_tree_node; - /** Whether a matcher may expand the columns of `expression_join_tree_node`. - * Set for an expression that is evaluated per row of that table expression (a row policy, - * `additional_table_filters`, `additional_result_filter`), where a matcher means the same as in - * the equivalent `WHERE`. Not set while resolving a constant expression (a `VALUES` field, an - * index analysis predicate): a matcher cannot produce a constant there and is rejected instead. - */ - bool allow_matcher_from_expression_join_tree_node = false; - /// Node hash to mask id map std::shared_ptr> projection_mask_map; diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index aec8ca2874e4..2a83e059e0b4 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -253,7 +253,6 @@ void QueryAnalyzer::resolve(QueryTreeNodePtr & node, const TableExpressionNodePt if (table_expression) { scope.expression_join_tree_node = table_expression; - scope.allow_matcher_from_expression_join_tree_node = true; scope.registered_table_expression_nodes.insert(table_expression); /// Mark table expression as being in resolve process to prevent /// premature access during ALIAS column resolution in @@ -1849,12 +1848,6 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::getMatchedColumnNodesWithN const auto * nearest_query_scope = scope.getNearestQueryScope(); if (nearest_query_scope) table_expression_data = &nearest_query_scope->getTableExpressionDataOrThrow(table_expression_node); - else if (scope.expression_join_tree_node) - { - /// An expression resolved against a single table expression (`additional_table_filters`, a row - /// policy, a column `DEFAULT`) has no query scope, and its table expression data is on this scope. - table_expression_data = &scope.getTableExpressionDataOrThrow(table_expression_node); - } QueryTreeNodes matched_column_nodes; @@ -1920,11 +1913,6 @@ void QueryAnalyzer::updateMatchedColumnsFromJoinUsing( /// If there are no parent query scope or query scope does not have join tree if (!nearest_query_scope_query_node || !nearest_query_scope_query_node->getJoinTreeNode()) { - /// An expression resolved against a single table expression has no join tree, hence no `USING` - /// key whose type a join could have changed. - if (scope.expression_join_tree_node && scope.allow_matcher_from_expression_join_tree_node) - return; - throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "There are no table sources. In scope {}", scope.scope_node->formatASTForErrorMessage()); @@ -2222,18 +2210,8 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( auto * nearest_query_scope = scope.getNearestQueryScope(); auto * nearest_query_scope_query_node = nearest_query_scope ? nearest_query_scope->scope_node->as() : nullptr; - /** An expression can also be resolved against a single table expression instead of a query: - * `additional_table_filters`, `additional_result_filter`, a row policy or a column `DEFAULT`. - * Such a scope has no query scope and no join tree, and the matcher expands the columns of that - * table expression, so that the filter sees the same columns as the equivalent `WHERE` predicate. - */ - QueryTreeNodePtr matcher_join_tree_node; - if (nearest_query_scope_query_node && nearest_query_scope_query_node->getJoinTreeNode()) - matcher_join_tree_node = nearest_query_scope_query_node->getJoinTreeNode(); - else if (scope.allow_matcher_from_expression_join_tree_node) - matcher_join_tree_node = scope.expression_join_tree_node; - - if (!matcher_join_tree_node) + /// If there are no parent query scope or query scope does not have join tree + if (!nearest_query_scope_query_node || !nearest_query_scope_query_node->getJoinTreeNode()) { throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "Unqualified matcher {} cannot be resolved. There are no table sources. In scope {}", @@ -2249,7 +2227,7 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( * expressions that have same names as columns in USING clause must be skipped. */ - auto table_expressions_stack = buildTableExpressionsStack(matcher_join_tree_node); + auto table_expressions_stack = buildTableExpressionsStack(nearest_query_scope_query_node->getJoinTreeNode()); std::vector table_expressions_column_nodes_with_names_stack; std::unordered_set table_expression_column_names_to_skip; @@ -2263,7 +2241,7 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( /// Old-analyzer parity: under two or more JOINs a list-form `COLUMNS` item keeps the written /// identifier. Recorded on a clone, so the shared resolved node keeps its own name. - bool keep_written_names = nearest_query_scope && nearest_query_scope->joins_count >= 2 + bool keep_written_names = nearest_query_scope->joins_count >= 2 && scope.context->getSettingsRef()[Setting::analyzer_compatibility_multiple_joins_qualify_column_names]; for (const auto & identifier : identifiers) @@ -2299,9 +2277,7 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( for (auto & table_expression : table_expressions_stack) { - /// Without a query scope the table expressions being resolved are tracked on this scope. - const auto & resolve_process_scope = nearest_query_scope ? *nearest_query_scope : scope; - bool table_expression_in_resolve_process = resolve_process_scope.table_expressions_in_resolve_process.contains(table_expression.get()); + bool table_expression_in_resolve_process = nearest_query_scope->table_expressions_in_resolve_process.contains(table_expression.get()); if (table_expression->as()) { diff --git a/src/Planner/Utils.cpp b/src/Planner/Utils.cpp index 67311e1635a3..e866bb702248 100644 --- a/src/Planner/Utils.cpp +++ b/src/Planner/Utils.cpp @@ -702,6 +702,28 @@ QueryTreeNodePtr buildFilterQueryTree(ASTPtr filter_expression, auto filter_query_tree = buildQueryTree(filter_expression, query_context); + /// A filter is a predicate over the rows of a single table expression, not a projection, so a column + /// matcher (`*`, `t.*`, `COLUMNS(...)`) has no meaning in it: it would only ever expand into the argument + /// list of a function such as `ignore(*)`, which is never a useful filter. Reject it deliberately, with a + /// clear diagnostic, instead of letting the analyzer fail on the missing table sources of such a scope. + /// A matcher inside a subquery of the filter, e.g. `x IN (SELECT * FROM allowed)`, resolves against that + /// subquery's own tables and is fine, so subqueries are not descended into. + traverseQueryTree( + filter_query_tree, + [](const QueryTreeNodePtr &, const QueryTreeNodePtr & child) + { + return !isQueryOrUnionNode(child); + }, + [&](const QueryTreeNodePtr & node) + { + if (node->getNodeType() == QueryTreeNodeType::MATCHER) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "Column matcher {} is not allowed in a filter expression (a row policy, `additional_table_filters` " + "or `additional_result_filter`); list the columns explicitly. In filter {}", + node->formatASTForErrorMessage(), + filter_query_tree->formatASTForErrorMessage()); + }); + QueryAnalysisPass query_analysis_pass(table_expression); query_analysis_pass.run(filter_query_tree, query_context); diff --git a/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql b/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql index fdfa7733f0c3..6c33784cc444 100644 --- a/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql +++ b/tests/queries/0_stateless/03571_trying_to_get_name_of_not_a_column_asterisk.sql @@ -5,5 +5,5 @@ DROP TABLE t0; DROP TABLE IF EXISTS 02577_keepermap_delete_update; CREATE TABLE 02577_keepermap_delete_update (key UInt64, value String, value2 UInt64) PRIMARY KEY(key); -DELETE FROM `02577_keepermap_delete_update` WHERE like(indexHint(*, indexHint(indexHint(toNullable(1.), 0, (20 IS NULL) IS NOT NULL, isNull(indexHint(indexHint(indexHint(indexHint(*), *), isZeroOrNull(materialize(indexHint(*, indexHint(toNullable(toInt128(100) IS NULL, 1.), toLowCardinality(0), isNullable(toNullable(20)) IS NULL, indexHint(isZeroOrNull(15), *, indexHint(*)), *), 100), 15))), indexHint(indexHint(isNullable(materialize(15 IS NOT NULL))), 100, *), 1, indexHint(indexHint(1, indexHint(100, *)), *)), toLowCardinality(15)), indexHint(*)), *)), value, 'Some%string'); -- { serverError UNKNOWN_IDENTIFIER, UNSUPPORTED_METHOD, NUMBER_OF_ARGUMENTS_DOESNT_MATCH } +DELETE FROM `02577_keepermap_delete_update` WHERE like(indexHint(*, indexHint(indexHint(toNullable(1.), 0, (20 IS NULL) IS NOT NULL, isNull(indexHint(indexHint(indexHint(indexHint(*), *), isZeroOrNull(materialize(indexHint(*, indexHint(toNullable(toInt128(100) IS NULL, 1.), toLowCardinality(0), isNullable(toNullable(20)) IS NULL, indexHint(isZeroOrNull(15), *, indexHint(*)), *), 100), 15))), indexHint(indexHint(isNullable(materialize(15 IS NOT NULL))), 100, *), 1, indexHint(indexHint(1, indexHint(100, *)), *)), toLowCardinality(15)), indexHint(*)), *)), value, 'Some%string'); -- { serverError UNKNOWN_IDENTIFIER, UNSUPPORTED_METHOD } DROP TABLE 02577_keepermap_delete_update; diff --git a/tests/queries/0_stateless/05194_matcher_in_additional_filters.reference b/tests/queries/0_stateless/05194_matcher_in_additional_filters.reference deleted file mode 100644 index 0d159396ed42..000000000000 --- a/tests/queries/0_stateless/05194_matcher_in_additional_filters.reference +++ /dev/null @@ -1,16 +0,0 @@ -WHERE 100 -unqualified 100 -COLUMNS 100 -qualified 100 -transformer 100 -alias column 100 -filtering 94 -filtering by an alias column 94 -result filter -0 -1 -2 -result filter, filtering -2 -row policy 100 -row policy, filtering 94 diff --git a/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql b/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql deleted file mode 100644 index b2be62855c2f..000000000000 --- a/tests/queries/0_stateless/05194_matcher_in_additional_filters.sql +++ /dev/null @@ -1,40 +0,0 @@ --- A column matcher inside `additional_table_filters`, `additional_result_filter` or a row policy is --- resolved against the table the filter applies to, just like the same predicate written in `WHERE`: --- such an expression is analyzed against a single table expression rather than a query, and matcher --- resolution used to reject that scope with `There are no table sources`. - -DROP TABLE IF EXISTS t_05194; -CREATE TABLE t_05194 (a UInt32, b UInt32, c UInt32 ALIAS a + b) ENGINE = MergeTree ORDER BY a; -INSERT INTO t_05194 SELECT number, number FROM numbers(100); - -SELECT 'WHERE', count() FROM t_05194 WHERE not ignore(*); -SELECT 'unqualified', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(*)'}; -SELECT 'COLUMNS', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(COLUMNS(\'.*\'))'}; -SELECT 'qualified', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(t_05194.*)'}; -SELECT 'transformer', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(* EXCEPT b)'}; -SELECT 'alias column', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'not ignore(*)'}, asterisk_include_alias_columns = 1; - --- The filter is applied, not merely accepted. -SELECT 'filtering', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'COLUMNS(\'b\') > 5'}; -SELECT 'filtering by an alias column', count() FROM t_05194 WHERE 1 SETTINGS additional_table_filters = {'t_05194': 'COLUMNS(\'c\') > 10'}; - --- `additional_result_filter` is applied on top of the whole plan, so the matcher expands the columns --- of the query result, and the filter sees the rows the `LIMIT` has already selected. Only a single --- column is printed: `additional_result_filter` also rearranges the result header, which has nothing --- to do with the matcher, so the two-column case is checked for resolution only. -SELECT 'result filter'; -SELECT a FROM t_05194 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'not ignore(*)'; -SELECT a, b FROM t_05194 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'not ignore(*)' FORMAT Null; -SELECT 'result filter, filtering'; -SELECT b FROM t_05194 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'COLUMNS(\'b\') > 1'; - -CREATE ROW POLICY OR REPLACE p_05194 ON t_05194 USING not ignore(*) TO ALL; -SELECT 'row policy', count() FROM t_05194; -CREATE ROW POLICY OR REPLACE p_05194 ON t_05194 USING COLUMNS('b') > 5 TO ALL; -SELECT 'row policy, filtering', count() FROM t_05194; -DROP ROW POLICY p_05194 ON t_05194; - --- A matcher is still rejected where the expression must be a constant, which --- `05051_values_format_names_the_missing_delimiter` pins for a `VALUES` field. - -DROP TABLE t_05194; diff --git a/tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.reference b/tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.reference new file mode 100644 index 000000000000..b087948fe72a --- /dev/null +++ b/tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.reference @@ -0,0 +1,6 @@ +subquery in a filter 10 +subquery in a row policy 10 +plain filter 94 +plain result filter +1 +2 diff --git a/tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.sql b/tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.sql new file mode 100644 index 000000000000..017bb702491f --- /dev/null +++ b/tests/queries/0_stateless/05227_matcher_in_filter_is_rejected.sql @@ -0,0 +1,37 @@ +-- A column matcher (`*`, `t.*`, `COLUMNS(...)`) in a row policy, `additional_table_filters` or +-- `additional_result_filter` is rejected with a clear diagnostic: a filter is a predicate over the +-- rows of one table, and a matcher there could only ever expand into the arguments of a function +-- such as `ignore(*)`. The analyzer used to fail on it with an obscure `There are no table sources`. + +DROP TABLE IF EXISTS t_05227; +DROP TABLE IF EXISTS allowed_05227; +CREATE TABLE t_05227 (a UInt32, b UInt32) ENGINE = MergeTree ORDER BY a; +INSERT INTO t_05227 SELECT number, number FROM numbers(100); +CREATE TABLE allowed_05227 (a UInt32) ENGINE = MergeTree ORDER BY a; +INSERT INTO allowed_05227 SELECT number FROM numbers(10); + +SELECT count() FROM t_05227 WHERE 1 SETTINGS additional_table_filters = {'t_05227': 'not ignore(*)'}; -- { serverError BAD_ARGUMENTS } +SELECT count() FROM t_05227 WHERE 1 SETTINGS additional_table_filters = {'t_05227': 'not ignore(COLUMNS(\'.*\'))'}; -- { serverError BAD_ARGUMENTS } +SELECT count() FROM t_05227 WHERE 1 SETTINGS additional_table_filters = {'t_05227': 'not ignore(t_05227.*)'}; -- { serverError BAD_ARGUMENTS } +SELECT count() FROM t_05227 WHERE 1 SETTINGS additional_table_filters = {'t_05227': 'not ignore(* EXCEPT b)'}; -- { serverError BAD_ARGUMENTS } +SELECT count() FROM t_05227 WHERE 1 SETTINGS additional_table_filters = {'t_05227': 'arrayMap(x -> ignore(*), [1])[1] = 0'}; -- { serverError BAD_ARGUMENTS } +SELECT a FROM t_05227 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'not ignore(*)'; -- { serverError BAD_ARGUMENTS } + +-- A matcher inside a subquery of the filter resolves against the subquery's own table and is fine. +SELECT 'subquery in a filter', count() FROM t_05227 WHERE 1 SETTINGS additional_table_filters = {'t_05227': 'a IN (SELECT * FROM allowed_05227)'}; + +CREATE ROW POLICY OR REPLACE p_05227 ON t_05227 USING not ignore(*) TO ALL; +SELECT count() FROM t_05227; -- { serverError BAD_ARGUMENTS } + +CREATE ROW POLICY OR REPLACE p_05227 ON t_05227 USING a IN (SELECT * FROM allowed_05227) TO ALL; +SELECT 'subquery in a row policy', count() FROM t_05227; +DROP ROW POLICY p_05227 ON t_05227; + +-- A matcher-free filter keeps working. +SELECT 'plain filter', count() FROM t_05227 WHERE 1 SETTINGS additional_table_filters = {'t_05227': 'b > 5'}; +-- `additional_result_filter` is applied on top of the rows the `LIMIT` has already selected. +SELECT 'plain result filter'; +SELECT a FROM t_05227 ORDER BY a LIMIT 3 SETTINGS additional_result_filter = 'a > 0'; + +DROP TABLE t_05227; +DROP TABLE allowed_05227; From fa4dba4485ab82e7299352cfe2a7c0c78fe7d89b Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 18 Sep 2026 04:18:06 +0000 Subject: [PATCH 090/272] Retry the `prefix.path` rewrites of an undo like the other undo stages Master (#119235) made every stage of a plain-rewritable undo repeat until object storage accepts it, because an undo that stops halfway leaves object storage describing a filesystem this process does not have. The undo stages that this branch adds - restoring the `prefix.path` of a directory whose explicit file list an operation rewrote - now go through `undoWithRetries` as well. Co-Authored-By: Claude Fable 5.1 --- ...PlainRewritableObjectStorageOperations.cpp | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp index c50b90288fa6..b08c036c2977 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp @@ -484,7 +484,10 @@ void MetadataStorageFromPlainObjectStorageWriteFileOperation::undo() return; LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageWriteFileOperation"), "Reversing the metadata rewrite for the directory of '{}'", path); - writeDirectoryMetadata(*object_storage, *layout, normalizePath(path).parent_path(), previous_directory_info.value()); + undoWithRetries(getLogger("MetadataStorageFromPlainObjectStorageWriteFileOperation"), fmt::format("restore the metadata of the directory of '{}'", path), [&] + { + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path).parent_path(), previous_directory_info.value()); + }); } void MetadataStorageFromPlainObjectStorageWriteFileOperation::finalize() @@ -568,7 +571,10 @@ void MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation::undo() if (prefix_path_written) { LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation"), "Reversing the metadata rewrite for the directory of '{}'", path); - writeDirectoryMetadata(*object_storage, *layout, normalizePath(path).parent_path(), previous_directory_info.value()); + undoWithRetries(getLogger("MetadataStorageFromPlainObjectStorageUnlinkMetadataFileOperation"), fmt::format("restore the metadata of the directory of '{}'", path), [&] + { + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path).parent_path(), previous_directory_info.value()); + }); return; } @@ -678,7 +684,10 @@ void MetadataStorageFromPlainObjectStorageCopyFileOperation::undo() if (prefix_path_written) { LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageCopyFileOperation"), "Reversing the metadata rewrite for the directory of '{}'", path_to); - writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info.value()); + undoWithRetries(getLogger("MetadataStorageFromPlainObjectStorageCopyFileOperation"), fmt::format("restore the metadata of the directory of '{}'", path_to), [&] + { + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info.value()); + }); } if (!copy_attempted) @@ -746,7 +755,10 @@ void MetadataStorageFromPlainObjectStorageHardLinkOperation::undo() return; LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageHardLinkOperation"), "Reversing the hard link '{}' to '{}'", path_to, path_from); - writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info.value()); + undoWithRetries(getLogger("MetadataStorageFromPlainObjectStorageHardLinkOperation"), fmt::format("restore the metadata of the directory of '{}'", path_to), [&] + { + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info.value()); + }); } MetadataStorageFromPlainObjectStorageMoveFileOperation::MetadataStorageFromPlainObjectStorageMoveFileOperation( @@ -905,10 +917,20 @@ void MetadataStorageFromPlainObjectStorageMoveFileOperation::undo() LOG_TRACE(getLogger("MetadataStorageFromPlainObjectStorageMoveFileOperation"), "Reversing the metadata rewrite for the move from '{}' to '{}'", path_from, path_to); if (prefix_path_written_to) - writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info_to.value()); + { + undoWithRetries(getLogger("MetadataStorageFromPlainObjectStorageMoveFileOperation"), fmt::format("restore the metadata of the directory of '{}'", path_to), [&] + { + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_to).parent_path(), previous_directory_info_to.value()); + }); + } if (prefix_path_written_from) - writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_from).parent_path(), previous_directory_info_from.value()); + { + undoWithRetries(getLogger("MetadataStorageFromPlainObjectStorageMoveFileOperation"), fmt::format("restore the metadata of the directory of '{}'", path_from), [&] + { + writeDirectoryMetadata(*object_storage, *layout, normalizePath(path_from).parent_path(), previous_directory_info_from.value()); + }); + } return; } From 9c0b41bd4ef190c09053d23eaa2f62fc56fb5bf8 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 18 Sep 2026 04:18:08 +0000 Subject: [PATCH 091/272] Address review: replay the hard-link fallback copy in the uncommitted state With `enable_hard_links = 0` a hard link is a copy that runs at commit, and the transaction did not replay it: a later rewrite of the target in the same transaction planned as the creation of a missing file, chose the same default key, and the copy overwrote the new bytes at commit. The copy is now recorded in the uncommitted state like any created file, and a rewrite of its target in the same transaction supersedes the copy: the target directory keeps the implicit form, where a file has exactly one key, so the copy cannot go anywhere else and has nothing to contribute once the target is rewritten. Covered by the new `HardLinksDisabledRewriteLinkInTheSameTransaction` test. Co-Authored-By: Claude Fable 5.1 --- ...torageFromPlainRewritableObjectStorage.cpp | 20 +++++++- ...aStorageFromPlainRewritableObjectStorage.h | 5 ++ ...PlainRewritableObjectStorageOperations.cpp | 6 +++ ...omPlainRewritableObjectStorageOperations.h | 6 +++ .../gtest_metadata_plain_rewritable_disk.cpp | 50 +++++++++++++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp index 90e16dbe4633..0af16a2a83a4 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.cpp @@ -631,13 +631,20 @@ void MetadataStorageFromPlainRewritableObjectStorageTransaction::createHardLink( /// A real hard link would make the metadata of the target directory unreadable by older servers, so it is opt-in. if (!metadata_storage.hard_links_enabled) { - operations.addOperation(std::make_unique( + /// The copy is a new file with its own blob at the default location of the target directory. The following + /// operations of this transaction have to see it, like any other created file: otherwise a rewrite of the + /// target would plan as the creation of a missing file, and the copy would overwrite it at commit. + uncommitted_state.recordCreatedFile(path_to, /*blob_key=*/""); + + auto copy = std::make_unique( path_from, path_to, commit_snapshot, metadata_storage.object_storage, metadata_storage.layout, - metadata_storage.metrics)); + metadata_storage.metrics); + fallback_copies[normalized_path_to.string()] = copy.get(); + operations.addOperation(std::move(copy)); return; } @@ -713,6 +720,15 @@ ObjectStorageKey MetadataStorageFromPlainRewritableObjectStorageTransaction::gen if (normalized_path.filename().empty()) throw Exception(ErrorCodes::LOGICAL_ERROR, "File name is empty for path '{}'", path); + /// The new blob replaces the copy that stood in for a hard link in this transaction, so the copy is redundant. + /// It would also go to the same default key at commit (the target directory keeps the implicit form, where a file + /// has one key) and overwrite the bytes that the caller writes to the key returned from here before the commit. + if (const auto it = fallback_copies.find(normalized_path.string()); it != fallback_copies.end()) + { + it->second->supersede(); + fallback_copies.erase(it); + } + const auto parent_path = normalized_path.parent_path(); const auto parent_info = uncommitted_state.getDirectoryRemoteInfo(parent_path); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h index 20931c8403c4..2fc0b636eecb 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h @@ -117,6 +117,8 @@ private: AtomicStopwatch previous_refresh; }; +class MetadataStorageFromPlainObjectStorageCopyFileOperation; + class MetadataStorageFromPlainRewritableObjectStorageTransaction : public IMetadataTransaction { protected: @@ -128,6 +130,9 @@ protected: StoredObjects removed_objects; /// Blob keys chosen by `generateObjectKeyForPath`, by normalized file path, for the files this transaction is going to create. std::unordered_map generated_blob_keys; + /// The copies that stand in for hard links while hard links are disabled, by the normalized path of the target. + /// A rewrite of the target in the same transaction supersedes its copy. + std::unordered_map fallback_copies; void planFileMove(const NormalizedPath & path_from, const NormalizedPath & path_to); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp index b08c036c2977..fbdc952020e3 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.cpp @@ -642,6 +642,12 @@ MetadataStorageFromPlainObjectStorageCopyFileOperation::MetadataStorageFromPlain void MetadataStorageFromPlainObjectStorageCopyFileOperation::execute() { + if (superseded) + { + LOG_TEST(getLogger("MetadataStorageFromPlainObjectStorageCopyFileOperation"), "Skipping the copy from '{}' to '{}': the transaction rewrites the target", path_from, path_to); + return; + } + LOG_TEST(getLogger("MetadataStorageFromPlainObjectStorageCopyFileOperation"), "Copying file from '{}' to '{}'", path_from, path_to); if (!fs_tree->existsFile(path_from)) diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h index b68c020c5895..b1d98d7befb1 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorageOperations.h @@ -216,6 +216,7 @@ private: bool copy_attempted = false; std::optional previous_directory_info; bool prefix_path_written = false; + bool superseded = false; public: MetadataStorageFromPlainObjectStorageCopyFileOperation( @@ -226,6 +227,11 @@ public: std::shared_ptr layout_, std::shared_ptr metrics_); + /// The same transaction rewrites the target after this copy, so the copy has nothing to contribute. It would also + /// go to the key of the rewritten blob and overwrite the new bytes at commit, because the copy stands in for a hard + /// link (hard links are disabled), and the target directory keeps the implicit form where a file has one key. + void supersede() { superseded = true; } + void execute() override; void undo() override; }; diff --git a/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp b/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp index d6cd097ba0f7..40c1f54a25ad 100644 --- a/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp +++ b/src/Disks/tests/gtest_metadata_plain_rewritable_disk.cpp @@ -2929,6 +2929,56 @@ TEST_F(MetadataPlainRewritableDiskTest, HardLinksDisabled) EXPECT_EQ(metadata->getFileSize("A/f2"), 4u); } +/// Without `enable_hard_links` a hard link is a copy that runs at commit. A rewrite of the link in the same transaction +/// has to keep the new bytes: the copy would otherwise go to the same key as the rewrite and overwrite them. +TEST_F(MetadataPlainRewritableDiskTest, HardLinksDisabledRewriteLinkInTheSameTransaction) +{ + thread_local_rng.seed(42); + + hard_links_enabled = false; + + const std::string test = "HardLinksDisabledRewriteLink"; + auto metadata = getMetadataStorage(test); + auto object_storage = getObjectStorage(test); + + { + auto tx = metadata->createTransaction(); + tx->createDirectory("A"); + tx->createDirectory("B"); + const auto key = tx->generateObjectKeyForPath("A/f1").serialize(); + size_t size = writeObject(object_storage, key, "old bytes"); + tx->createMetadataFile("A/f1", {StoredObject(key, "A/f1", size)}); + tx->commit(DB::NoCommitOptions{}); + } + + { + auto tx = metadata->createTransaction(); + tx->createHardLink("A/f1", "B/f1"); + + /// The link exists for the rest of the transaction, so the rewrite is planned as a rewrite of an existing file. + const auto key = tx->generateObjectKeyForPath("B/f1").serialize(); + EXPECT_TRUE(key.ends_with("/f1")); + size_t size = writeObject(object_storage, key, "new bytes"); + tx->createMetadataFile("B/f1", {StoredObject(key, "B/f1", size)}); + tx->commit(DB::NoCommitOptions{}); + } + + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "old bytes"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "new bytes"); + EXPECT_NE(metadata->getStorageObjects("A/f1").front().remote_path, metadata->getStorageObjects("B/f1").front().remote_path); + EXPECT_EQ(metadata->getFileSize("B/f1"), 9u); + + /// The layout stays in the implicit form. + EXPECT_FALSE(parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "A"))).has_explicit_file_list); + EXPECT_FALSE(parsePrefixPath(readObject(object_storage, createMetadataObjectPath(metadata, "B"))).has_explicit_file_list); + + metadata = restartMetadataStorage(test); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("A/f1").front().remote_path), "old bytes"); + EXPECT_EQ(readObject(object_storage, metadata->getStorageObjects("B/f1").front().remote_path), "new bytes"); + EXPECT_EQ(metadata->getFileSize("B/f1"), 9u); + EXPECT_EQ(listAllBlobs(test).size(), 4u); /// Two `prefix.path` objects and two blobs. +} + TEST_F(MetadataPlainRewritableDiskTest, UnlinkSharedFileUndo) { thread_local_rng.seed(42); From 2aebd54474e724c27c34adf13ba1560fd2cd5eb4 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 18 Sep 2026 04:18:37 +0000 Subject: [PATCH 092/272] Address review: remove the root state of a `table_disk` table after its parts `StorageMergeTree::drop` removed the `mutation_*` entries and the deduplication log of a `table_disk` table before `dropAllData` started. A drop that fails while removing the parts is retried by `DatabaseCatalog`, and `UNDROP TABLE` can still restore the table until then, so a failed drop could resurrect the table with all of its parts and without its mutation queue or deduplication history. The cleanup now runs from the late root-cleanup phase of `dropAllData`, after the parts are gone, through a `MergeTreeData` hook that the engines override for the files they keep in the root of the disk. The new test pauses a drop before it removes the parts and checks that a readonly table over the same directory still loads the mutation and the deduplication log. Co-Authored-By: Claude Fable 5.1 --- src/Common/FailPoint.cpp | 1 + src/Storages/MergeTree/MergeTreeData.cpp | 6 ++ src/Storages/MergeTree/MergeTreeData.h | 6 ++ src/Storages/StorageMergeTree.cpp | 55 +++++++--------- src/Storages/StorageMergeTree.h | 6 +- ...ps_mutations_until_parts_removed.reference | 7 +++ ...rop_keeps_mutations_until_parts_removed.sh | 63 +++++++++++++++++++ 7 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.reference create mode 100755 tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.sh diff --git a/src/Common/FailPoint.cpp b/src/Common/FailPoint.cpp index b78281489f7f..df990f3de88e 100644 --- a/src/Common/FailPoint.cpp +++ b/src/Common/FailPoint.cpp @@ -233,6 +233,7 @@ static struct InitFiu REGULAR(zero_copy_unlock_zk_fail_after_op) \ REGULAR(plain_rewritable_object_storage_azure_not_found_on_init) \ PAUSEABLE(storage_merge_tree_background_clear_old_parts_pause) \ + PAUSEABLE(merge_tree_drop_all_data_pause_before_removing_parts) \ PAUSEABLE(storage_merge_create_children_plans_pause) \ PAUSEABLE_ONCE(storage_shared_merge_tree_mutate_pause_before_wait) \ PAUSEABLE(database_replicated_startup_pause) \ diff --git a/src/Storages/MergeTree/MergeTreeData.cpp b/src/Storages/MergeTree/MergeTreeData.cpp index 3efe769b9fbc..2803d9a487a2 100644 --- a/src/Storages/MergeTree/MergeTreeData.cpp +++ b/src/Storages/MergeTree/MergeTreeData.cpp @@ -398,6 +398,7 @@ namespace ServerSetting namespace FailPoints { + extern const char merge_tree_drop_all_data_pause_before_removing_parts[]; extern const char claim_inject_stale_part_dir[]; } @@ -4752,6 +4753,8 @@ void MergeTreeData::dropAllData() } LOG_TRACE(log, "dropAllData: waiting for locks."); + + FailPointInjection::pauseFailPoint(FailPoints::merge_tree_drop_all_data_pause_before_removing_parts); auto settings_ptr = getSettings(); auto lock = lockParts(); @@ -4855,6 +4858,9 @@ void MergeTreeData::dropAllData() LOG_INFO(log, "dropAllData: remove format_version.txt, detached, moving and write ahead logs"); disk->removeFileIfExists(fs::path(relative_data_path) / FORMAT_VERSION_FILE_NAME); + if ((*settings_ptr)[MergeTreeSetting::table_disk]) + removeOwnFilesInDiskRootOnDrop(disk); + if (disk->existsDirectory(fs::path(relative_data_path) / DETACHED_DIR_NAME)) { if (disk->supportZeroCopyReplication()) diff --git a/src/Storages/MergeTree/MergeTreeData.h b/src/Storages/MergeTree/MergeTreeData.h index 97b5e4116708..e56ebd57c75f 100644 --- a/src/Storages/MergeTree/MergeTreeData.h +++ b/src/Storages/MergeTree/MergeTreeData.h @@ -1086,6 +1086,12 @@ public: /// Deletes the data directory and flushes the uncompressed blocks cache and the marks cache. void dropAllData(); + /// With the `table_disk` setting the table directory is the root of the disk, which `dropAllData` cannot remove + /// recursively, so the files that the engine keeps there (besides the parts and the directories it removes by + /// name) would survive the drop and get loaded by the next table created on the same disk. Called for each + /// writable disk after the parts are removed, so that a failed drop can still be retried or undone with them. + virtual void removeOwnFilesInDiskRootOnDrop(const DiskPtr & /*disk*/) {} + /// This flag is for hardening and assertions. bool all_data_dropped = false; diff --git a/src/Storages/StorageMergeTree.cpp b/src/Storages/StorageMergeTree.cpp index 1e3b6d13ae53..36bb2fc745a4 100644 --- a/src/Storages/StorageMergeTree.cpp +++ b/src/Storages/StorageMergeTree.cpp @@ -123,7 +123,6 @@ namespace MergeTreeSetting extern const MergeTreeSettingsMergeTreePatchPartsVersion patch_parts_version; extern const MergeTreeSettingsBool always_use_copy_instead_of_hardlinks; extern const MergeTreeSettingsBool assign_part_uuids; - extern const MergeTreeSettingsBool table_disk; extern const MergeTreeSettingsDeduplicateMergeProjectionMode deduplicate_merge_projection_mode; extern const MergeTreeSettingsBool enable_replacing_merge_with_cleanup_for_min_age_to_force_merge; extern const MergeTreeSettingsUInt64 finished_mutations_to_keep; @@ -468,47 +467,37 @@ StorageMergeTree::write(const ASTPtr & /*query*/, const StorageMetadataPtr & met void StorageMergeTree::drop() { shutdown(true); - - /// With the `table_disk` setting the table directory is the root of the disk, which `dropAllData` cannot remove - /// recursively (see `MetadataStorageFromPlainObjectStorageRemoveRecursiveOperation`), so the state that this table - /// keeps there would survive the drop and get loaded by the next table created on the same disk. - if ((*getSettings())[MergeTreeSetting::table_disk]) - removeOwnFilesInDiskRootOnDrop(); - dropAllData(); } -void StorageMergeTree::removeOwnFilesInDiskRootOnDrop() +void StorageMergeTree::removeOwnFilesInDiskRootOnDrop(const DiskPtr & disk) { - for (const auto & disk : getDisks()) + /// Runs after the parts are removed (see `MergeTreeData::dropAllData`): a drop that fails while removing the parts + /// is retried, and `UNDROP TABLE` can restore the table until then, so the mutation entries and the deduplication + /// log have to outlive the parts they describe. + size_t removed_count = 0; + for (auto it = disk->iterateDirectory(relative_data_path); it->isValid(); it->next()) { - if (disk->isBroken() || disk->isReadOnly()) - continue; - - size_t removed_count = 0; - for (auto it = disk->iterateDirectory(relative_data_path); it->isValid(); it->next()) + if (startsWith(it->name(), "mutation_") || startsWith(it->name(), "tmp_mutation_")) { - if (startsWith(it->name(), "mutation_") || startsWith(it->name(), "tmp_mutation_")) - { - LOG_DEBUG(log, "Removing mutation file {} on drop", it->path()); - disk->removeFile(it->path()); - ++removed_count; - } - } - - /// Otherwise the next table created on the same disk loads the block numbers of this one and deduplicates - /// (silently skips) its inserts. - const auto deduplication_logs_path = fs::path(relative_data_path) / DEDUPLICATION_LOGS_DIR_NAME; - if (disk->existsDirectory(deduplication_logs_path)) - { - LOG_DEBUG(log, "Removing the deduplication log {} on drop", deduplication_logs_path.string()); - disk->removeRecursive(deduplication_logs_path); + LOG_DEBUG(log, "Removing mutation file {} on drop", it->path()); + disk->removeFile(it->path()); ++removed_count; } - - if (removed_count > 0) - LOG_INFO(log, "Removed {} entries of this table from the root of the disk {} on drop", removed_count, disk->getName()); } + + /// Otherwise the next table created on the same disk loads the block numbers of this one and deduplicates + /// (silently skips) its inserts. + const auto deduplication_logs_path = fs::path(relative_data_path) / DEDUPLICATION_LOGS_DIR_NAME; + if (disk->existsDirectory(deduplication_logs_path)) + { + LOG_DEBUG(log, "Removing the deduplication log {} on drop", deduplication_logs_path.string()); + disk->removeRecursive(deduplication_logs_path); + ++removed_count; + } + + if (removed_count > 0) + LOG_INFO(log, "Removed {} entries of this table from the root of the disk {} on drop", removed_count, disk->getName()); } void StorageMergeTree::alter( diff --git a/src/Storages/StorageMergeTree.h b/src/Storages/StorageMergeTree.h index d08dc2f6c3d4..8d7fc5c28326 100644 --- a/src/Storages/StorageMergeTree.h +++ b/src/Storages/StorageMergeTree.h @@ -188,9 +188,9 @@ private: const bool support_transaction; void loadMutations(); - /// Removes the state that this table keeps in the root of its disk, which `dropAllData` leaves alone - /// when the table occupies the whole disk (`table_disk`). - void removeOwnFilesInDiskRootOnDrop(); + /// Removes the mutation entries and the deduplication log, which `dropAllData` leaves alone when the table + /// occupies the whole disk (`table_disk`). + void removeOwnFilesInDiskRootOnDrop(const DiskPtr & disk) override; /// Load and initialize deduplication logs. Even if deduplication setting /// equals zero creates object with deduplication window equals zero. diff --git a/tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.reference b/tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.reference new file mode 100644 index 000000000000..79d3a97e5845 --- /dev/null +++ b/tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.reference @@ -0,0 +1,7 @@ +1 1 +-- a readonly table over the same directory still sees the parts, the mutation and the deduplication log +3 3 +1 +1 +-- the completed drop leaves no objects behind +0 diff --git a/tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.sh b/tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.sh new file mode 100755 index 000000000000..728d2259a1d5 --- /dev/null +++ b/tests/queries/0_stateless/05228_table_disk_drop_keeps_mutations_until_parts_removed.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-parallel, no-shared-merge-tree, no-replicated-database +# Tag no-fasttest: requires S3 +# Tag no-parallel: uses a server-wide failpoint that pauses the drop of every MergeTree table +# Tag no-shared-merge-tree: does not support replication +# Tag no-replicated-database: plain rewritable should not be shared between replicas + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# With `table_disk` the table occupies the root of the disk, so `DROP TABLE` removes the mutation entries and the +# deduplication log by name. A drop that fails while removing the parts is retried, and `UNDROP TABLE` can restore the +# table until then, so that state has to stay until the parts are gone: a readonly table over the same directory still +# loads the mutation while the drop is paused before removing the parts. + +endpoint="http://localhost:11111/test/${CLICKHOUSE_TEST_UNIQUE_NAME}/" +disk_args="type = s3_plain_rewritable, endpoint = '${endpoint}', access_key_id = clickhouse, secret_access_key = clickhouse, enable_hard_links = 1" +failpoint="merge_tree_drop_all_data_pause_before_removing_parts" + +function cleanup() +{ + ${CLICKHOUSE_CLIENT} --query "SYSTEM DISABLE FAILPOINT ${failpoint}" 2>/dev/null +} +trap cleanup EXIT + +${CLICKHOUSE_CLIENT} -m --query " +DROP TABLE IF EXISTS writer SYNC; +DROP TABLE IF EXISTS reader SYNC; + +CREATE TABLE writer (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk(name = '${CLICKHOUSE_TEST_UNIQUE_NAME}_writer', ${disk_args}), + non_replicated_deduplication_window = 100; + +INSERT INTO writer VALUES (1, 'a'), (2, 'b'), (3, 'c'); +ALTER TABLE writer UPDATE value = concat(value, '!') WHERE 1 SETTINGS mutations_sync = 1; +SELECT count(), countIf(is_done) FROM system.mutations WHERE database = currentDatabase() AND table = 'writer'; +" + +${CLICKHOUSE_CLIENT} --query "SYSTEM ENABLE FAILPOINT ${failpoint}" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE writer SYNC" & +drop_pid=$! + +# The drop is about to remove the parts. +${CLICKHOUSE_CLIENT} --query "SYSTEM WAIT FAILPOINT ${failpoint} PAUSE" + +echo '-- a readonly table over the same directory still sees the parts, the mutation and the deduplication log' +${CLICKHOUSE_CLIENT} -m --query " +CREATE TABLE reader (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk(readonly = true, name = '${CLICKHOUSE_TEST_UNIQUE_NAME}_reader', ${disk_args}); +SELECT count(), countIf(endsWith(value, '!')) FROM reader; +SELECT count() FROM system.mutations WHERE database = currentDatabase() AND table = 'reader'; +SELECT count() > 0 FROM s3('${endpoint}**', 'clickhouse', 'clickhouse', 'One') WHERE _path LIKE '%deduplication_log%'; +" + +# The failpoint pauses every drop, so the readonly table is dropped only after it is disabled. +${CLICKHOUSE_CLIENT} --query "SYSTEM DISABLE FAILPOINT ${failpoint}" +wait ${drop_pid} +${CLICKHOUSE_CLIENT} --query "DROP TABLE reader SYNC" + +echo '-- the completed drop leaves no objects behind' +${CLICKHOUSE_CLIENT} --query "SELECT count() FROM s3('${endpoint}**', 'clickhouse', 'clickhouse', 'One')" From 0e12a4d0f59d28e23592ce0dab29a589f6ad7602 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Fri, 18 Sep 2026 04:18:38 +0000 Subject: [PATCH 093/272] Report a mutation entry that the owner of a shared directory removed while a readonly table loaded A readonly `MergeTree` table over the directory of a live table (`table_disk` on the same `plain_rewritable` endpoint) does not own the `mutation_*.txt` entries there: the live table removes one at any moment (`KILL MUTATION`, `clearOldMutations`), also between the listing and the read in `loadMutations`. The read then failed with a raw `S3_ERROR` (`The specified key does not exist`), which is what reverted #110939: the stress test kills random mutations and treats every such error as a bug, see https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?REF=master&sha=3b3489bfd27660683f874666359fc92f58ca46b8&name_0=MasterCI&name_1=Stress%20test%20%28amd_debug%29 The table still cannot load without the entry - a part below its version would miss the commands, and no reader-side rule can tell a killed mutation from a finished one (see the analysis in #119154) - so the load still fails, but with `FILE_DOESNT_EXIST` and the reason: the entry was removed concurrently, and the attach can be retried after `SYSTEM DROP DISK METADATA CACHE` reloads the metadata of the readonly disk, which still lists the entry. The check probes the backend, because the cached metadata of the disk is exactly what is stale. Only a readonly disk takes this path. Related: https://github.com/ClickHouse/ClickHouse/pull/119154 Co-Authored-By: Claude Fable 5.1 --- src/Common/FailPoint.cpp | 1 + src/Storages/StorageMergeTree.cpp | 34 ++++++++- src/Storages/StorageMergeTree.h | 2 + ...currently_removed_mutation_entry.reference | 9 +++ ...ble_concurrently_removed_mutation_entry.sh | 76 +++++++++++++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.reference create mode 100755 tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.sh diff --git a/src/Common/FailPoint.cpp b/src/Common/FailPoint.cpp index df990f3de88e..19fce70b7b4f 100644 --- a/src/Common/FailPoint.cpp +++ b/src/Common/FailPoint.cpp @@ -233,6 +233,7 @@ static struct InitFiu REGULAR(zero_copy_unlock_zk_fail_after_op) \ REGULAR(plain_rewritable_object_storage_azure_not_found_on_init) \ PAUSEABLE(storage_merge_tree_background_clear_old_parts_pause) \ + PAUSEABLE(storage_merge_tree_load_mutations_pause_before_read) \ PAUSEABLE(merge_tree_drop_all_data_pause_before_removing_parts) \ PAUSEABLE(storage_merge_create_children_plans_pause) \ PAUSEABLE_ONCE(storage_shared_merge_tree_mutate_pause_before_wait) \ diff --git a/src/Storages/StorageMergeTree.cpp b/src/Storages/StorageMergeTree.cpp index 36bb2fc745a4..15c74dc4a4dc 100644 --- a/src/Storages/StorageMergeTree.cpp +++ b/src/Storages/StorageMergeTree.cpp @@ -84,6 +84,7 @@ namespace DB namespace FailPoints { extern const char storage_merge_tree_background_clear_old_parts_pause[]; + extern const char storage_merge_tree_load_mutations_pause_before_read[]; extern const char mt_merge_selecting_task_pause_when_scheduled[]; extern const char mt_select_parts_to_mutate_no_free_threads[]; extern const char mt_select_parts_to_mutate_max_part_size[]; @@ -160,6 +161,7 @@ namespace ErrorCodes extern const int PART_IS_TEMPORARILY_LOCKED; extern const int FAULT_INJECTED; extern const int INVALID_TRANSACTION; + extern const int FILE_DOESNT_EXIST; } namespace ActionLocks @@ -1663,6 +1665,34 @@ void StorageMergeTree::loadDeduplicationLog() } } +MergeTreeMutationEntry StorageMergeTree::loadMutationEntry(const DiskPtr & disk, const String & file_name) const +{ + try + { + return MergeTreeMutationEntry(disk, relative_data_path, file_name); + } + catch (const Exception & e) + { + /// A readonly table over the directory of a live table (`table_disk` on a shared `plain_rewritable` endpoint) + /// does not own the entries: the owner removes one at any moment (`KILL MUTATION`, `clearOldMutations`), also + /// between the listing and this read. The table cannot load without the entry - a part below its version + /// would miss the commands - so the load fails, but with the reason instead of a missing object: the error + /// is transient, and the caller can retry once the metadata of the disk, which still lists the entry, is reloaded. + if (!disk->isReadOnly() || disk->checkUniqueId(disk->getUniqueId(fs::path(relative_data_path) / file_name))) + throw; + + throw Exception( + ErrorCodes::FILE_DOESNT_EXIST, + "The mutation entry {} of the readonly table {} was removed by the table that owns the directory while this " + "table was loading it. Reload the metadata of the disk (SYSTEM DROP DISK METADATA CACHE {}) and retry the " + "attach. The read failed with: {}", + file_name, + getStorageID().getNameForLogs(), + backQuoteIfNeed(disk->getName()), + e.message()); + } +} + void StorageMergeTree::loadMutations() { auto component_guard = Coordination::setCurrentComponent("StorageMergeTree::loadMutations"); @@ -1680,7 +1710,9 @@ void StorageMergeTree::loadMutations() { if (startsWith(it->name(), "mutation_")) { - MergeTreeMutationEntry entry(disk, relative_data_path, it->name()); + FailPointInjection::pauseFailPoint(FailPoints::storage_merge_tree_load_mutations_pause_before_read); + + MergeTreeMutationEntry entry = loadMutationEntry(disk, it->name()); UInt64 block_number = entry.block_number; LOG_DEBUG(log, "Loading mutation: {} entry, commands size: {}", it->name(), entry.commands->size()); diff --git a/src/Storages/StorageMergeTree.h b/src/Storages/StorageMergeTree.h index 8d7fc5c28326..bd2165cea963 100644 --- a/src/Storages/StorageMergeTree.h +++ b/src/Storages/StorageMergeTree.h @@ -188,6 +188,8 @@ private: const bool support_transaction; void loadMutations(); + /// Reads a `mutation_*.txt` entry, reporting an entry that the owner of a shared directory removed meanwhile. + MergeTreeMutationEntry loadMutationEntry(const DiskPtr & disk, const String & file_name) const; /// Removes the mutation entries and the deduplication log, which `dropAllData` leaves alone when the table /// occupies the whole disk (`table_disk`). void removeOwnFilesInDiskRootOnDrop(const DiskPtr & disk) override; diff --git a/tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.reference b/tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.reference new file mode 100644 index 000000000000..865697e7b68f --- /dev/null +++ b/tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.reference @@ -0,0 +1,9 @@ +-- the mutation of the live table is pending +1 0 +-- the readonly table reports the concurrently removed entry, not a missing object +the reason is reported +FILE_DOESNT_EXIST is reported +S3_ERROR is not reported +-- the next attempt succeeds once the metadata of the disk, which still lists the entry, is reloaded +100 +0 diff --git a/tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.sh b/tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.sh new file mode 100755 index 000000000000..8c5f147d5631 --- /dev/null +++ b/tests/queries/0_stateless/05227_readonly_table_concurrently_removed_mutation_entry.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-parallel, no-shared-merge-tree, no-replicated-database +# Tag no-fasttest: requires S3 +# Tag no-parallel: uses a server-wide failpoint that pauses the loading of mutations of every MergeTree table +# Tag no-shared-merge-tree: does not support replication +# Tag no-replicated-database: plain rewritable should not be shared between replicas + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# A readonly table over the directory of a live table (`table_disk` on the same endpoint) does not own the mutation +# entries there: the live table removes one at any moment, also between the listing and the read while the readonly +# table loads. The load fails with the reason instead of a missing object, and the next attempt succeeds. + +endpoint="http://localhost:11111/test/${CLICKHOUSE_TEST_UNIQUE_NAME}/" +disk_args="type = s3_plain_rewritable, endpoint = '${endpoint}', access_key_id = clickhouse, secret_access_key = clickhouse, enable_hard_links = 1" +failpoint="storage_merge_tree_load_mutations_pause_before_read" + +function cleanup() +{ + ${CLICKHOUSE_CLIENT} --query "SYSTEM DISABLE FAILPOINT ${failpoint}" 2>/dev/null +} +trap cleanup EXIT + +${CLICKHOUSE_CLIENT} -m --query " +DROP TABLE IF EXISTS writer SYNC; +DROP TABLE IF EXISTS reader SYNC; + +CREATE TABLE writer (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk(name = '${CLICKHOUSE_TEST_UNIQUE_NAME}_writer', ${disk_args}); + +INSERT INTO writer SELECT number, toString(number) FROM numbers(100); + +SYSTEM STOP MERGES writer; +ALTER TABLE writer UPDATE value = concat(value, '!') WHERE 1 SETTINGS mutations_sync = 0; +" + +echo '-- the mutation of the live table is pending' +${CLICKHOUSE_CLIENT} --query "SELECT count(), countIf(is_done) FROM system.mutations WHERE database = currentDatabase() AND table = 'writer'" + +${CLICKHOUSE_CLIENT} --query "SYSTEM ENABLE FAILPOINT ${failpoint}" + +reader_log="${CLICKHOUSE_TMP}/${CLICKHOUSE_TEST_UNIQUE_NAME}_reader.log" +${CLICKHOUSE_CLIENT} --query " +CREATE TABLE reader (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk(readonly = true, name = '${CLICKHOUSE_TEST_UNIQUE_NAME}_reader', ${disk_args}) +" > "${reader_log}" 2>&1 & +reader_pid=$! + +# The readonly table has listed the entry and is about to read it. +${CLICKHOUSE_CLIENT} --query "SYSTEM WAIT FAILPOINT ${failpoint} PAUSE" + +# The live table removes the entry meanwhile. +${CLICKHOUSE_CLIENT} --query "KILL MUTATION WHERE database = currentDatabase() AND table = 'writer' SYNC FORMAT Null" +${CLICKHOUSE_CLIENT} --query "SYSTEM DISABLE FAILPOINT ${failpoint}" + +wait ${reader_pid} && echo 'the readonly table attached without the entry' || true + +echo '-- the readonly table reports the concurrently removed entry, not a missing object' +grep -q 'was removed by the table that owns the directory while this table was loading it' "${reader_log}" && echo 'the reason is reported' +grep -q 'FILE_DOESNT_EXIST' "${reader_log}" && echo 'FILE_DOESNT_EXIST is reported' +grep -q 'S3_ERROR' "${reader_log}" && echo 'S3_ERROR is reported' || echo 'S3_ERROR is not reported' + +echo '-- the next attempt succeeds once the metadata of the disk, which still lists the entry, is reloaded' +${CLICKHOUSE_CLIENT} -m --query " +SYSTEM DROP DISK METADATA CACHE ${CLICKHOUSE_TEST_UNIQUE_NAME}_reader; +CREATE TABLE reader (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk(readonly = true, name = '${CLICKHOUSE_TEST_UNIQUE_NAME}_reader', ${disk_args}); +SELECT count() FROM reader; +SELECT count() FROM system.mutations WHERE database = currentDatabase() AND table = 'reader'; + +DROP TABLE reader SYNC; +SYSTEM START MERGES writer; +DROP TABLE writer SYNC; +" From 4a13ffd5e5c0b49f2e61b56846f1d6ec6c5b3726 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 19 Sep 2026 06:46:13 +0000 Subject: [PATCH 094/272] Cover the outdated parts left on disk with the empty part of a drop range `removePartsInRangeFromWorkingSetAndGetPartsToRemoveFromZooKeeper` writes an empty part covering the range it drops, so that a restart before the removed parts are unlinked does not resurrect them. Its block range, level and mutation version were derived from the active parts being removed only. Parts that had been outdated earlier are still on disk - they are unlinked only once `old_parts_lifetime` has passed - and the ones that no active part covers (a part removed by `DROP PART`, or an empty part dropped in the background) are seen by the part loader after a restart. `MergeTreePartInfo::contains` demands a strictly greater level when the block ranges differ, so such a leftover whose level is not below the level of the covering part is neither contained in it nor disjoint from it, and `PartLoadingTree::add` throws `Part {} intersects previous part {}. It is a bug or a result of manual intervention`. The table cannot be attached, and when it happens during startup the server cannot start at all. Take the leftovers inside the dropped block range into account as well when computing the range, the level and the mutation version of the covering part. Found by the `Stress test (arm_debug)` job on master, where concurrent `ATTACH PARTITION FROM` / `REPLACE PARTITION` / `MOVE PARTITION` between two tables (`04004_move_partition_bidirectional_deadlock`) left `1_17_17_1` on disk next to a covering part `1_12_60_1`: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?REF=master&sha=bac1295e647c578c6f181ee07b693bcbe8daf328&name_0=MasterCI&name_1=Stress%20test%20%28arm_debug%29 Co-Authored-By: Claude Opus 5 --- src/Storages/MergeTree/MergeTreeData.cpp | 34 +++++++++++++---- ..._partition_covers_outdated_parts.reference | 1 + ...eplace_partition_covers_outdated_parts.sql | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference create mode 100644 tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql diff --git a/src/Storages/MergeTree/MergeTreeData.cpp b/src/Storages/MergeTree/MergeTreeData.cpp index f9a44c2b6753..96f0ab38c2e0 100644 --- a/src/Storages/MergeTree/MergeTreeData.cpp +++ b/src/Storages/MergeTree/MergeTreeData.cpp @@ -7368,14 +7368,34 @@ MergeTreeData::PartsToRemoveFromZooKeeper MergeTreeData::removePartsInRangeFromW MergeTreePartInfo empty_info = drop_range; empty_info.level = empty_info.mutation = 0; empty_info.min_block = MergeTreePartInfo::MAX_BLOCK_NUMBER; - for (const auto & part : parts_to_remove) + + /// We still have to take min_block into account to avoid creating multiple covering ranges + /// that intersect each other. The level and the mutation version matter just as much: + /// `MergeTreePartInfo::contains` demands a strictly greater level when the block ranges + /// differ, so a covering part whose level is not above the level of everything it spans + /// does not actually cover it. + auto widen_to_cover = [&](const MergeTreePartInfo & info) { - /// We still have to take min_block into account to avoid creating multiple covering ranges - /// that intersect each other - empty_info.min_block = std::min(empty_info.min_block, part->info.min_block); - empty_info.level = std::max(empty_info.level, part->info.level); - empty_info.mutation = std::max(empty_info.mutation, part->info.mutation); - } + empty_info.min_block = std::min(empty_info.min_block, info.min_block); + empty_info.level = std::max(empty_info.level, info.level); + empty_info.mutation = std::max(empty_info.mutation, info.mutation); + }; + + for (const auto & part : parts_to_remove) + widen_to_cover(part->info); + + /// Parts that were outdated earlier are still on disk: they are only unlinked once + /// `old_parts_lifetime` has passed. The ones that no active part covers - a part dropped by + /// `DROP PART`, or an empty part dropped in the background - are seen by the part loader + /// after a restart. The covering part has to contain those as well, otherwise the loader + /// finds a pair of parts that neither contain one another nor are disjoint, and the server + /// refuses to start with "Part ... intersects previous part ...". + /// Only the block range is checked here (not `drop_range.contains`), because a part that + /// lies inside the dropped blocks has to be covered whatever its level and mutation are. + for (const auto & part : inactive_parts_to_remove_immediately) + if (drop_range.min_block <= part->info.min_block && part->info.max_block <= drop_range.max_block) + widen_to_cover(part->info); + empty_info.level += 1; const auto & source_part = parts_to_remove.front(); diff --git a/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference new file mode 100644 index 000000000000..7c897f126eec --- /dev/null +++ b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference @@ -0,0 +1 @@ +1 3 diff --git a/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql new file mode 100644 index 000000000000..db4d7e7da355 --- /dev/null +++ b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql @@ -0,0 +1,37 @@ +-- Tags: no-shared-merge-tree, no-replicated-database +-- no-shared-merge-tree: the test is about the covering part that plain `MergeTree` writes on `REPLACE PARTITION`. +-- no-replicated-database: the test relies on the block numbers of a single, freshly created table. + +-- `REPLACE PARTITION` writes an empty part that covers the replaced ones, so that a restart before +-- they are unlinked does not resurrect them. Parts that were outdated earlier are still on disk as +-- well, and the covering part has to contain those too - otherwise the part loader finds a pair of +-- parts that neither contain one another nor are disjoint, and the table fails to attach with +-- "Part ... intersects previous part ...". + +DROP TABLE IF EXISTS t_replace_cover_dst; +DROP TABLE IF EXISTS t_replace_cover_src; + +-- Keep the outdated parts on disk for the whole test, as they would be right after a `REPLACE PARTITION` in production. +CREATE TABLE t_replace_cover_dst (p UInt64, k UInt64) ENGINE = MergeTree PARTITION BY p ORDER BY k +SETTINGS old_parts_lifetime = 100000, merge_tree_clear_old_parts_interval_seconds = 100000, remove_empty_parts = 1; + +CREATE TABLE t_replace_cover_src (p UInt64, k UInt64) ENGINE = MergeTree PARTITION BY p ORDER BY k; + +INSERT INTO t_replace_cover_dst SETTINGS async_insert = 0 VALUES (1, 1); +INSERT INTO t_replace_cover_dst SETTINGS async_insert = 0 VALUES (1, 2); + +-- `DROP PART` covers the part with an empty part of level 1, which is then dropped for being empty. +-- That leaves `1_2_2_1` outdated on disk with no active part covering it. +ALTER TABLE t_replace_cover_dst DROP PART '1_2_2_0'; + +INSERT INTO t_replace_cover_src SETTINGS async_insert = 0 VALUES (1, 3); + +ALTER TABLE t_replace_cover_dst REPLACE PARTITION 1 FROM t_replace_cover_src; + +DETACH TABLE t_replace_cover_dst; +ATTACH TABLE t_replace_cover_dst; + +SELECT * FROM t_replace_cover_dst ORDER BY k; + +DROP TABLE t_replace_cover_dst; +DROP TABLE t_replace_cover_src; From 6b77916f0e0b6885850996fc7fad2758f9c6c9b3 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 19 Sep 2026 17:08:58 +0000 Subject: [PATCH 095/272] Give the `-Resample` case of 05045 a non-empty range `05045_uniq_state_version_foreach_map_combinators` checked that `-Resample` spells the nested state version out with `uniqResampleState(1, 1, 1)(1, 1)`. The three parameters are `begin`, `end` and `step`, and `end == begin` makes `total` zero, so that parameterisation names an empty range. Master rejects it since 880ef882b0775062cb04217a54827799567fab7b ("Reject aggregate function parameterisations whose state serializes to zero bytes"): the `-Resample` constructor now throws `ARGUMENT_OUT_OF_BOUND` with "The range given in function uniqResample is empty", because such a state holds no nested state, serializes to zero bytes and cannot be read back element by element. Nothing in this branch conflicts with that textually, so the merge is clean and the pull request's own run stayed green, but the test started failing as soon as the merge queue built it on top of that commit - in `Fast test` and in all five repetitions of the `Stateless tests (amd_binary, flaky check)` job: https://github.com/ClickHouse/ClickHouse/actions/runs/35424732673 https://github.com/ClickHouse/ClickHouse/pull/115393 The range is incidental to what the line checks - it only needs a `-Resample` state to read a type name off - so widen it to `(1, 2, 1)`, which is the smallest non-empty range. The assertion is unchanged: the state type still has to carry the explicit version `1` that the nested `uniq` pins. Co-Authored-By: Claude Opus 5 --- .../05045_uniq_state_version_foreach_map_combinators.reference | 2 +- .../05045_uniq_state_version_foreach_map_combinators.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference index 133aad065480..7daf576b338f 100644 --- a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference +++ b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.reference @@ -7,7 +7,7 @@ AggregateFunction(1, uniqDistinct, UInt8) AggregateFunction(1, uniqOrNull, UInt8) AggregateFunction(1, uniqOrDefault, UInt8) AggregateFunction(1, uniqArgMin, UInt8, UInt8) -AggregateFunction(1, uniqResample(1, 1, 1), UInt8, UInt8) +AggregateFunction(1, uniqResample(1, 2, 1), UInt8, UInt8) AggregateFunction(uniqExact, Nullable(UInt8)) AggregateFunction(sumMap, Map(UInt8, UInt8)) [3,100315] diff --git a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql index 97f6c6e7e9e1..a42e3b551846 100644 --- a/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql +++ b/tests/queries/0_stateless/05045_uniq_state_version_foreach_map_combinators.sql @@ -21,7 +21,7 @@ SELECT toTypeName(uniqDistinctState(1)); SELECT toTypeName(uniqOrNullState(1)); SELECT toTypeName(uniqOrDefaultState(1)); SELECT toTypeName(uniqArgMinState(1, 1)); -SELECT toTypeName(uniqResampleState(1, 1, 1)(1, 1)); +SELECT toTypeName(uniqResampleState(1, 2, 1)(1, 1)); SELECT toTypeName(uniqExactState(toNullable(1))); SELECT toTypeName(sumMapState(map(1, 2))); From 4a3c11e3e89b36cd7bc4144c52835f0bb1f99188 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 19 Sep 2026 19:34:24 +0000 Subject: [PATCH 096/272] Address review: cover the destination partition that has no active parts left `REPLACE PARTITION` into a partition whose parts were all dropped earlier reaches `removePartsInRangeFromWorkingSetAndGetPartsToRemoveFromZooKeeper` with an empty `parts_to_remove` and writes no covering part. It does not have to: `DROP PART` of the part that covered the others leaves an empty part of a higher level on top of them, so the leftovers still cover one another. The loader activates that empty part, not the data underneath it, and `grabOldParts` unlinks it only once everything it covers is gone (`EMPTY_PART_COVERS_OTHER_PARTS`). Co-Authored-By: Claude Opus 5 (1M context) --- ..._partition_covers_outdated_parts.reference | 1 + ...eplace_partition_covers_outdated_parts.sql | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference index 7c897f126eec..bb0797bcd049 100644 --- a/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference +++ b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.reference @@ -1 +1,2 @@ 1 3 +1 3 diff --git a/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql index db4d7e7da355..8fa79fad84fa 100644 --- a/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql +++ b/tests/queries/0_stateless/05229_replace_partition_covers_outdated_parts.sql @@ -35,3 +35,38 @@ SELECT * FROM t_replace_cover_dst ORDER BY k; DROP TABLE t_replace_cover_dst; DROP TABLE t_replace_cover_src; + +-- The destination partition can also be left with no active parts at all: `DROP PART` of the part +-- that covered the others leaves only outdated parts on disk. `REPLACE PARTITION` then has nothing +-- active to remove, and it does not have to write a covering part for the leftovers: they still +-- cover one another, so the loader activates the empty part that stands on top of them, and +-- `grabOldParts` unlinks that part only once everything it covers is gone. Nothing is resurrected. + +DROP TABLE IF EXISTS t_replace_cover_all_outdated_dst; +DROP TABLE IF EXISTS t_replace_cover_all_outdated_src; + +CREATE TABLE t_replace_cover_all_outdated_dst (p UInt64, k UInt64) ENGINE = MergeTree PARTITION BY p ORDER BY k +SETTINGS old_parts_lifetime = 100000, merge_tree_clear_old_parts_interval_seconds = 100000, remove_empty_parts = 1; + +CREATE TABLE t_replace_cover_all_outdated_src (p UInt64, k UInt64) ENGINE = MergeTree PARTITION BY p ORDER BY k; + +INSERT INTO t_replace_cover_all_outdated_dst SETTINGS async_insert = 0 VALUES (1, 1); +INSERT INTO t_replace_cover_all_outdated_dst SETTINGS async_insert = 0 VALUES (1, 2); + +-- Merge, so that the two parts that hold the rows stay on disk under the merged `1_1_2_1`. +OPTIMIZE TABLE t_replace_cover_all_outdated_dst PARTITION 1 FINAL; + +-- Dropping the only active part leaves `1_1_1_0` and `1_2_2_0` on disk under the empty `1_1_2_2`. +ALTER TABLE t_replace_cover_all_outdated_dst DROP PART '1_1_2_1'; + +INSERT INTO t_replace_cover_all_outdated_src SETTINGS async_insert = 0 VALUES (1, 3); + +ALTER TABLE t_replace_cover_all_outdated_dst REPLACE PARTITION 1 FROM t_replace_cover_all_outdated_src; + +DETACH TABLE t_replace_cover_all_outdated_dst; +ATTACH TABLE t_replace_cover_all_outdated_dst; + +SELECT * FROM t_replace_cover_all_outdated_dst ORDER BY k; + +DROP TABLE t_replace_cover_all_outdated_dst; +DROP TABLE t_replace_cover_all_outdated_src; From f23524239d4ae22191df22c22570b70fc21220ef Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 20 Sep 2026 02:39:40 +0000 Subject: [PATCH 097/272] Do not qualify preserved-side column names against a hidden SEMI/ANTI join side `SELECT *` (and the projection name of an explicitly qualified column) is qualified with the table alias whenever the bare column name also binds to another table expression of the scope. With `semi_join_compatibility` or `anti_join_compatibility` enabled the non-preserved side of a `SEMI`/`ANTI` `JOIN` is not part of the result, yet it still counted as a competing binder in `tryBindIdentifierToTableExpressions`, so `SELECT * FROM t1 RIGHT SEMI JOIN t2 ON ...` with a column `a` on both sides kept the only surviving column named `t2.a`, and an outer `SELECT a FROM (...)` failed with `UNKNOWN_IDENTIFIER`. Add `isTableExpressionHiddenBySemiAntiJoin`, which follows the join tree of the nearest query scope down to the table expression and asks `SemiAntiJoinSideChecker` for every `JOIN` on the path, and skip such table expressions in `tryBindIdentifierToTableExpressions`. The check is a no-op while both settings are disabled. Extend `03913_semi_anti_join_column_access` with nested-subquery regressions for all four join shapes, the qualified matcher, the explicitly qualified column, a `SEMI JOIN` nested below another `JOIN`, and a control with the setting disabled. Addresses the AI review finding in https://github.com/ClickHouse/ClickHouse/pull/96662#discussion_r3913362020 Co-Authored-By: Claude Fable 5.1 --- src/Analyzer/Resolve/IdentifierResolver.cpp | 70 +++++++++++++++++++ src/Analyzer/Resolve/IdentifierResolver.h | 10 +++ ...913_semi_anti_join_column_access.reference | 15 ++++ .../03913_semi_anti_join_column_access.sql | 24 +++++++ 4 files changed, 119 insertions(+) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index 178a23f27a31..67b67c4fc5f1 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -705,6 +705,13 @@ bool IdentifierResolver::tryBindIdentifierToTableExpressions(const IdentifierLoo if (table_expression_node.get() == table_expression_node_to_ignore.get()) continue; + /// The columns of a hidden SEMI/ANTI JOIN side are not visible here, so they must not make a + /// name of the preserved side ambiguous: otherwise `SELECT *` over `t1 LEFT SEMI JOIN t2` with + /// a column `a` on both sides would keep the only surviving column named `t1.a`, and an outer + /// query could no longer refer to it as `a` although `t2` is not part of the result at all. + if (isTableExpressionHiddenBySemiAntiJoin(table_expression_node.get(), scope)) + continue; + can_bind_identifier_to_table_expression = tryBindIdentifierToTableExpression(identifier_lookup, table_expression_node, scope); if (can_bind_identifier_to_table_expression) break; @@ -1422,6 +1429,69 @@ bool SemiAntiJoinSideChecker::shouldSkipSide(JoinTableSide side) const return (skip_left && side == JoinTableSide::Left) || (skip_right && side == JoinTableSide::Right); } +bool IdentifierResolver::isTableExpressionHiddenBySemiAntiJoin( + const IQueryTreeNode * table_expression_node, + const IdentifierResolveScope & scope) +{ + const auto & settings = scope.context->getSettingsRef(); + if (!settings[Setting::semi_join_compatibility] && !settings[Setting::anti_join_compatibility]) + return false; + + const auto * nearest_query_scope = scope.getNearestQueryScope(); + if (!nearest_query_scope) + return false; + + const auto * query_node = nearest_query_scope->scope_node->as(); + if (!query_node || !query_node->getJoinTreeNode()) + return false; + + /// Follow the path from the root of the join tree down to the table expression. Every SEMI/ANTI + /// JOIN on that path hides the side which contains the table expression if the compatibility + /// setting says so, unless its own ON expression is being resolved (`SemiAntiJoinSideChecker`). + const IQueryTreeNode * current = query_node->getJoinTreeNode().get(); + while (current && current != table_expression_node) + { + if (const auto * join_node = current->as()) + { + const auto * left = join_node->getLeftTableExpressionNode().get(); + const auto * right = join_node->getRightTableExpressionNode().get(); + const bool is_from_left = joinSubtreeContains(left, table_expression_node); + const bool is_from_right = !is_from_left && joinSubtreeContains(right, table_expression_node); + if (!is_from_left && !is_from_right) + return false; + + SemiAntiJoinSideChecker checker(*join_node, join_node->getStrictness(), join_node->getKind(), scope.context, scope.resolving_join_on_expression); + if (checker.shouldSkipSide(is_from_left ? JoinTableSide::Left : JoinTableSide::Right)) + return true; + + current = is_from_left ? left : right; + } + else if (const auto * cross_join_node = current->as()) + { + const IQueryTreeNode * next = nullptr; + for (const auto & table_expression : cross_join_node->getTableExpressions()) + { + if (joinSubtreeContains(table_expression.get(), table_expression_node)) + { + next = table_expression.get(); + break; + } + } + current = next; + } + else if (const auto * array_join_node = current->as()) + { + current = array_join_node->getTableExpressionNode().get(); + } + else + { + return false; + } + } + + return false; +} + void SemiAntiJoinSideChecker::throwIfTableAccessDenied( JoinTableSide side, const IQueryTreeNode & node_for_error_message, diff --git a/src/Analyzer/Resolve/IdentifierResolver.h b/src/Analyzer/Resolve/IdentifierResolver.h index 413e62b9d586..81431e9c3bbf 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.h +++ b/src/Analyzer/Resolve/IdentifierResolver.h @@ -86,11 +86,21 @@ public: const TableExpressionNodePtr & table_expression_node, const IdentifierResolveScope & scope); + /// Check whether the identifier binds to any table expression of the scope other than `table_expression_node`. + /// A table expression which a SEMI/ANTI JOIN hides from the current context (see `isTableExpressionHiddenBySemiAntiJoin`) + /// is not a competing binder: its columns are not visible, so they cannot make another name ambiguous. static bool tryBindIdentifierToTableExpressions( const IdentifierLookup & identifier_lookup, const TableExpressionNodePtr & table_expression_node, const IdentifierResolveScope & scope); + /// Returns true if `table_expression_node` is on the non-preserved side of a SEMI/ANTI JOIN of the nearest + /// query scope's join tree and `semi_join_compatibility` / `anti_join_compatibility` hides that side from + /// everything outside that join's own ON expression (see `SemiAntiJoinSideChecker`). + static bool isTableExpressionHiddenBySemiAntiJoin( + const IQueryTreeNode * table_expression_node, + const IdentifierResolveScope & scope); + static bool tryBindIdentifierToArrayJoinExpressions( const IdentifierLookup & identifier_lookup, const IdentifierResolveScope & scope); diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index d82df1a14195..8327c9cfe0e7 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -42,3 +42,18 @@ 42 1 2 +1 +2 +1 +2 +1 +1 +a b +1 2 +t1.a t2.a +1 3 +t1.a t2.a +1 3 +a t2.a +1 2 +2 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 2a00c3bed1df..2cd7ba73ad3c 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -366,3 +366,27 @@ SELECT if(false, cte_r.b, 42) FROM (SELECT 1 AS a) t1 LEFT ANTI JOIN cte_r ON fa -- A materialized CTE on the preserved (right) side of a RIGHT SEMI JOIN stays accessible. WITH cte_r AS MATERIALIZED (SELECT 2 AS b) SELECT cte_r.b FROM (SELECT 1 AS a) t1 RIGHT SEMI JOIN cte_r ON true; + +-- `SELECT *` and explicit column references must not stay qualified because of the hidden side. +-- The projection names of matched columns are qualified only when the bare name binds to another +-- table expression of the scope; a hidden SEMI/ANTI side is not part of the result, so its column +-- `a` must not turn the preserved-side `a` into `t1.a` — an outer query refers to it as `a`. +SELECT a FROM (SELECT * FROM (SELECT 1 AS a) AS t1 LEFT SEMI JOIN (SELECT 2 AS a) AS t2 ON true); +SELECT a FROM (SELECT * FROM (SELECT 1 AS a) AS t1 RIGHT SEMI JOIN (SELECT 2 AS a) AS t2 ON true); +SELECT a FROM (SELECT * FROM (SELECT 1 AS a) AS t1 LEFT ANTI JOIN (SELECT 2 AS a) AS t2 ON false); +SELECT a FROM (SELECT * FROM (SELECT 1 AS a) AS t1 RIGHT ANTI JOIN (SELECT 2 AS a) AS t2 ON false); +-- The qualified matcher and an explicitly qualified column of the preserved side get the bare name too. +SELECT a FROM (SELECT t1.* FROM (SELECT 1 AS a) AS t1 LEFT SEMI JOIN (SELECT 2 AS a) AS t2 ON true); +SELECT a FROM (SELECT t1.a FROM (SELECT 1 AS a) AS t1 LEFT SEMI JOIN (SELECT 2 AS a) AS t2 ON true); +-- The result header carries the bare names. +SELECT * FROM (SELECT 1 AS a, 2 AS b) AS t1 LEFT SEMI JOIN (SELECT 3 AS a) AS t2 ON true FORMAT TSVWithNames; +-- A SEMI JOIN nested below another JOIN: the hidden `t3.a` must not qualify `t1.a`, while `t2.a` +-- of the visible outer join side still does. +SELECT * FROM (SELECT 1 AS a) AS t1 LEFT SEMI JOIN (SELECT 2 AS a) AS t3 ON true LEFT JOIN (SELECT 3 AS a) AS t2 ON t1.a = t2.a - 2 FORMAT TSVWithNames; +SELECT * FROM (SELECT 1 AS a) AS t1 LEFT SEMI JOIN (SELECT 2 AS a) AS t3 ON true LEFT JOIN (SELECT 3 AS a) AS t2 ON true FORMAT TSVWithNames; +-- With the compatibility setting disabled, both sides are returned and the right side stays +-- qualified as before (the left side of a single join is never qualified, see `single_join_prefer_left_table`). +SET semi_join_compatibility = 0; +SELECT * FROM (SELECT 1 AS a) AS t1 RIGHT SEMI JOIN (SELECT 2 AS a) AS t2 ON true FORMAT TSVWithNames; +SELECT t2.a FROM (SELECT * FROM (SELECT 1 AS a) AS t1 RIGHT SEMI JOIN (SELECT 2 AS a) AS t2 ON true); +SET semi_join_compatibility = 1; From bd0a626cd340070cd74280b55f13d55ac21aa8b7 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 20 Sep 2026 05:54:39 +0000 Subject: [PATCH 098/272] Keep the wide-set test out of the `uniq` hash loop `Performance Comparison (arm_release)` measured `injective_functions_inside_uniq` at +23.9% and +23.6% - both queries reduce to `SELECT uniq(number) FROM numbers(50000000)`, which spends its whole time in `UniquesHashSet::insertMany`. Three unrelated pull requests measured on the same test in the same week moved it by +0.7%, +1.6% and +2.5%, so the slowdown is this change's. The cause is the per-element `wide_candidates += !(hash_value[j] & wide_prefilter)` in the loop that computes the hashes: an `and`, a compare and an accumulate against the dozen instructions the loop otherwise executes per value, and the accumulator is a reduction across the batch. Detect the candidates behind `good()` instead. Every wide candidate passes it - the wide thinning starts at 18 zero low-order bits and `skip_degree` of the main set never exceeds `UNIQUES_HASH_BITS_FOR_SKIP + 1` = 16 - and `good()` already rejects all but one value in `2 ^ skip_degree`, so the test runs on a path the data almost never takes. The hash loop goes back to exactly what it was before the wide set existed. Co-Authored-By: Claude Opus 5 (1M context) --- src/AggregateFunctions/UniquesHashSet.h | 36 ++++++++++++++----------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/AggregateFunctions/UniquesHashSet.h b/src/AggregateFunctions/UniquesHashSet.h index 8bc8b32db33f..c34132a0c247 100644 --- a/src/AggregateFunctions/UniquesHashSet.h +++ b/src/AggregateFunctions/UniquesHashSet.h @@ -644,10 +644,14 @@ public: requires std::is_invocable_r_v void insertMany(const SourceType * data, size_t size) { - /** The candidates for the wide set (see the comment at `widePrefilterMask`) are only detected - * in the main loop - branchlessly, on the truncated hashes - and are inserted after it: - * a call inside the loop would make the compiler cautiously reload the fields of the set - * from memory all the time, slowing the loop down by tens of percent. + /** The candidates for the wide set (see the comment at `widePrefilterMask`) are detected + * behind the `good()` test, which every one of them passes and which already rejects all + * but one value in 2^skip_degree - so the loop that computes the hashes is left exactly as + * it was before the wide set existed. Testing every hash there instead cost 24% of + * `SELECT uniq(number) FROM numbers(50000000)`: three instructions per value against the + * dozen the loop otherwise executes. + * The candidates are inserted after the loop rather than inside it: a call inside would + * make the compiler cautiously reload the fields of the set from memory all the time. * A candidate is expected once per 262144 values, so the positions are collected * into a small buffer; on its overflow (pathological input) the tail after the last * recorded position is simply rescanned - insertion into the wide set is idempotent. @@ -668,20 +672,12 @@ public: /// We calculate place() even for !good() hashes to maximize data independence and enable better out-of-order execution. /// The extra work is negligible compared to the instruction level parallelization benefits. std::array hash_value; // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - filled by the loop below before read - size_t wide_candidates = 0; for (size_t j = 0; j < insert_many_batch_size; ++j) { hash_value[j] = hash(Transform(data[i + j])); - wide_candidates += !(hash_value[j] & wide_prefilter); - } - - if (unlikely(wide_candidates)) - { - for (size_t j = 0; j < insert_many_batch_size; ++j) - if ((hash_value[j] & wide_prefilter) == 0 && num_wide_candidates < wide_candidate_positions.size()) - wide_candidate_positions[num_wide_candidates++] = static_cast(i + j); } + const size_t batch_begin = i; i += insert_many_batch_size; std::array place_value_batch; // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init) - filled by the loop below before read @@ -696,6 +692,13 @@ public: if (!good(x)) continue; + /// Every wide candidate passes `good()` (see `widePrefilterMask`), and `good()` + /// rejects all but one value in 2^skip_degree, so testing the candidates here + /// rather than in the hash loop above keeps the hot loop exactly as it was + /// before the wide set existed. + if (unlikely((x & wide_prefilter) == 0) && num_wide_candidates < wide_candidate_positions.size()) + wide_candidate_positions[num_wide_candidates++] = static_cast(batch_begin + j); + if (x == 0) { m_size += !has_zero; @@ -724,14 +727,15 @@ public: else { const HashValue hash_value = hash(Transform(data[i])); - - if (unlikely((hash_value & wide_prefilter) == 0) && num_wide_candidates < wide_candidate_positions.size()) - wide_candidate_positions[num_wide_candidates++] = static_cast(i); + const size_t position = i; i++; if (!good(hash_value)) continue; + if (unlikely((hash_value & wide_prefilter) == 0) && num_wide_candidates < wide_candidate_positions.size()) + wide_candidate_positions[num_wide_candidates++] = static_cast(position); + insertImpl(hash_value); } From 13507c7d3bb4f6a2cabe4cf5ebd018b4803e1ecf Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 20 Sep 2026 05:54:42 +0000 Subject: [PATCH 099/272] Forward `getVersionFromRevision` from the `Merge` combinator `AggregateFunctionMerge` forwards `isVersioned` and `getDefaultVersion` to the nested function but not `getVersionFromRevision`, so it answered the base class's `0` for every revision while claiming to be versioned. It is the only one of the thirteen combinators that carry these that does not forward all three. No shape of `...MergeState...` was found where this is observable: a round trip of `uniqMergeState` and `uniqMergeStateIf` over `remote()`, with and without `enable_parallel_blocks_marshalling`, announces `AggregateFunction(1, uniq, UInt64)` and writes a byte-identical payload either way, because the state type carries the version through the chain on its own. Raised by the review as a `Native` / `ColumnBLOB` downgrade; that part did not reproduce, and no test is added for a behaviour change that cannot be observed. Co-Authored-By: Claude Opus 5 (1M context) --- .../Combinators/AggregateFunctionMerge.h | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/AggregateFunctions/Combinators/AggregateFunctionMerge.h b/src/AggregateFunctions/Combinators/AggregateFunctionMerge.h index b80eb99657f7..d565bb47e39c 100644 --- a/src/AggregateFunctions/Combinators/AggregateFunctionMerge.h +++ b/src/AggregateFunctions/Combinators/AggregateFunctionMerge.h @@ -47,6 +47,19 @@ public: return nested_func->isVersioned(); } + /** `Merge` was the one combinator that forwarded `isVersioned` and `getDefaultVersion` but not + * this, so it answered the base class's `0` for every revision while claiming to be versioned. + * No shape of `...MergeState...` was found where that is observable - the state type carries + * the version through the chain on its own, and a round trip of `uniqMergeState` / + * `uniqMergeStateIf` over `remote()` was verified to announce `AggregateFunction(1, uniq, ...)` + * and to write a byte-identical payload either way - but a function that reports a version + * independent of the revision it is asked about is a trap for the next caller of it. + */ + size_t getVersionFromRevision(size_t revision) const override + { + return nested_func->getVersionFromRevision(revision); + } + size_t getDefaultVersion() const override { return nested_func->getDefaultVersion(); From b313c9b269c693aef232fb9574b0d23e667355e4 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 20 Sep 2026 22:58:07 +0000 Subject: [PATCH 100/272] Keep USING keys on the preserved side of SEMI/ANTI joins Address review: when `semi_join_compatibility` / `anti_join_compatibility` hide the non-preserved side of a `SEMI`/`ANTI` JOIN, a `USING` key was still widened to the `USING` supertype, which is derived from the hidden side too. So SELECT toTypeName(id) FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) exposed `UInt16`, leaking the type of a table that is not part of the result, even though the contract of these settings is that the result is exactly the preserved side. The same held for `SELECT *`, `l.*`, and the `RIGHT` / `ANTI` variants. The key is now kept on the preserved side in all three places that widened it: - `createProjectionForUsing` skips the supertype conversion when a side is hidden (the preserved side of a `SEMI`/`ANTI` JOIN can never need `join_use_nulls` either, since only its own rows are preserved), - `tryResolveIdentifierFromJoin` skips `convert_resolved_result_type_if_needed` and `updateMatchedColumnsFromJoinUsing` skips the matched-column type override for the preserved side, - `buildJoinUsingCondition` no longer registers the preserved side's key in `changed_types`, so the planner keeps its type in the join output. The join condition still reads the supertype cast, so the matched rows are unchanged. All of this is a no-op unless one of the two compatibility settings is enabled. Extends `03913_semi_anti_join_column_access` with `toTypeName` and `TSVWithNamesAndTypes` regressions for `SELECT *`, `l.*`, `SELECT id` and a preserved-side qualified reference, for `LEFT`/`RIGHT` `SEMI`/`ANTI`, multi-column `USING`, `Nullable`/`LowCardinality` keys, and controls with the settings off. A row-level case with `Int8` and `UInt8` keys pins that `-1` and `255` still do not match, i.e. only the declared type changed, not the result. Co-Authored-By: Claude Opus 5 (1M context) --- src/Analyzer/Resolve/IdentifierResolver.cpp | 48 +++++++++++++++---- src/Analyzer/Resolve/IdentifierResolver.h | 4 ++ src/Analyzer/Resolve/QueryAnalyzer.cpp | 43 +++++++++++------ src/Planner/PlannerJoinsLogical.cpp | 27 ++++++++++- ...913_semi_anti_join_column_access.reference | 47 ++++++++++++++++++ .../03913_semi_anti_join_column_access.sql | 39 +++++++++++++++ 6 files changed, 182 insertions(+), 26 deletions(-) diff --git a/src/Analyzer/Resolve/IdentifierResolver.cpp b/src/Analyzer/Resolve/IdentifierResolver.cpp index 67b67c4fc5f1..87ec3006ef99 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.cpp +++ b/src/Analyzer/Resolve/IdentifierResolver.cpp @@ -1318,9 +1318,9 @@ static bool innerJoinKeyColumnsAreEquated( * Example, for "SELECT id FROM t1 FULL JOIN t2 USING (id)" * this creates "SELECT firstNonDefault(t1.id, t2.id) AS id FROM ..." to coalesce the values appropriately. */ -QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, JoinKind join_kind, IdentifierResolveScope & scope); +QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, JoinKind join_kind, IdentifierResolveScope & scope, std::optional preserved_side); -QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, JoinKind join_kind, IdentifierResolveScope & scope) +QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, JoinKind join_kind, IdentifierResolveScope & scope, std::optional preserved_side) { const auto & using_expression = using_column_node.getExpression(); if (!using_expression) @@ -1331,14 +1331,26 @@ QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, if (arguments.size() < 2) throw Exception(ErrorCodes::LOGICAL_ERROR, "Expected at least 2 arguments for USING projection, but got {}", arguments.size()); - for (size_t i = 0; i < arguments.size(); ++i) + /** When `semi_join_compatibility` / `anti_join_compatibility` hides one side of the JOIN, the + * visible `USING` key must keep the preserved side's own type: widening it to the `USING` + * supertype would expose the type of a table that is not part of the result at all. + * The preserved side never needs `join_use_nulls` either - a `SEMI`/`ANTI` JOIN only preserves + * rows of that side, so nothing of it can become NULL - so the conversion is skipped entirely. + */ + if (!preserved_side) { - auto resolved_side = (i + 1 == arguments.size()) ? JoinTableSide::Right : JoinTableSide::Left; - auto converted_argument = IdentifierResolver::convertJoinedColumnTypeToNullIfNeeded(arguments[i], using_column_node.getResultType(), join_kind, resolved_side, scope); - if (converted_argument) - arguments[i] = converted_argument; + for (size_t i = 0; i < arguments.size(); ++i) + { + auto resolved_side = (i + 1 == arguments.size()) ? JoinTableSide::Right : JoinTableSide::Left; + auto converted_argument = IdentifierResolver::convertJoinedColumnTypeToNullIfNeeded(arguments[i], using_column_node.getResultType(), join_kind, resolved_side, scope); + if (converted_argument) + arguments[i] = converted_argument; + } } + if (preserved_side == JoinTableSide::Right) + return arguments.back(); + if (join_kind == JoinKind::Right) return arguments.back(); @@ -1429,6 +1441,15 @@ bool SemiAntiJoinSideChecker::shouldSkipSide(JoinTableSide side) const return (skip_left && side == JoinTableSide::Left) || (skip_right && side == JoinTableSide::Right); } +std::optional SemiAntiJoinSideChecker::preservedSideOrNone() const +{ + if (skip_left) + return JoinTableSide::Right; + if (skip_right) + return JoinTableSide::Left; + return {}; +} + bool IdentifierResolver::isTableExpressionHiddenBySemiAntiJoin( const IQueryTreeNode * table_expression_node, const IdentifierResolveScope & scope) @@ -1602,6 +1623,8 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I } SemiAntiJoinSideChecker side_checker(from_join_node, join_strictness, join_kind, scope.context, scope.resolving_join_on_expression); + /// Set when one side of this JOIN is hidden by `semi_join_compatibility` / `anti_join_compatibility`. + const auto using_preserved_side = side_checker.preservedSideOrNone(); std::optional denied_qualified_access; bool ambiguous_in_join_tree = false; @@ -1868,7 +1891,7 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I if (using_column_node_it != join_using_column_name_to_column_node.end()) { const auto & using_column_node = using_column_node_it->second->as(); - resolved_identifier = createProjectionForUsing(using_column_node, join_kind, scope); + resolved_identifier = createProjectionForUsing(using_column_node, join_kind, scope, using_preserved_side); } else if (resolvedIdenfiersFromJoinAreEquals(left_resolved_identifier, right_resolved_identifier, scope)) { @@ -1930,7 +1953,10 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I } else { - convert_resolved_result_type_if_needed(left_resolved_identifier, join_using_column_name_to_column_node, resolved_identifier, scope, node_to_projection_name); + /// The right side is hidden by the compatibility settings, so the `USING` key must keep + /// the preserved left side's type instead of the `USING` supertype. + if (using_preserved_side != JoinTableSide::Left) + convert_resolved_result_type_if_needed(left_resolved_identifier, join_using_column_name_to_column_node, resolved_identifier, scope, node_to_projection_name); } } else if (right_resolved_identifier) @@ -1944,7 +1970,9 @@ IdentifierResolveResult IdentifierResolver::tryResolveIdentifierFromJoin(const I } else { - convert_resolved_result_type_if_needed(right_resolved_identifier, join_using_column_name_to_column_node, resolved_identifier, scope, node_to_projection_name); + /// Same as above, with the left side hidden and the right side preserved. + if (using_preserved_side != JoinTableSide::Right) + convert_resolved_result_type_if_needed(right_resolved_identifier, join_using_column_name_to_column_node, resolved_identifier, scope, node_to_projection_name); } } diff --git a/src/Analyzer/Resolve/IdentifierResolver.h b/src/Analyzer/Resolve/IdentifierResolver.h index 81431e9c3bbf..a6a4b07e15d9 100644 --- a/src/Analyzer/Resolve/IdentifierResolver.h +++ b/src/Analyzer/Resolve/IdentifierResolver.h @@ -44,6 +44,10 @@ struct SemiAntiJoinSideChecker bool shouldSkipSide(JoinTableSide side) const; + /// The side that stays visible when the compatibility settings hide the other one. + /// Returns nullopt when both sides remain visible. + std::optional preservedSideOrNone() const; + /// Throw if access to the given side of a SEMI/ANTI JOIN is denied. /// The caller is responsible for determining the correct side (e.g. via isFromJoinTree). void throwIfTableAccessDenied( diff --git a/src/Analyzer/Resolve/QueryAnalyzer.cpp b/src/Analyzer/Resolve/QueryAnalyzer.cpp index 13ea8071468d..c6ee946ec2e0 100644 --- a/src/Analyzer/Resolve/QueryAnalyzer.cpp +++ b/src/Analyzer/Resolve/QueryAnalyzer.cpp @@ -2190,6 +2190,20 @@ void QueryAnalyzer::updateMatchedColumnsFromJoinUsing( bool join_node_in_resolve_process = nearest_query_scope->table_expressions_in_resolve_process.contains(join_node); if (!join_node_in_resolve_process && join_node && join_node->isUsingJoinExpression()) { + /** When `semi_join_compatibility` / `anti_join_compatibility` hides one side of the JOIN, only the + * preserved side is part of the result, so its `USING` key must keep its own type rather than be + * widened to the `USING` supertype - which is derived from the hidden side as well. + */ + SemiAntiJoinSideChecker semi_anti_using_checker( + *join_node, + join_node->getStrictness(), + join_node->getKind(), + scope.context, + scope.resolving_join_on_expression); + + if (semi_anti_using_checker.preservedSideOrNone()) + return; + const auto & join_using_list = join_node->getJoinExpression()->as(); const auto & join_using_nodes = join_using_list.getNodes(); @@ -2535,7 +2549,7 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveQualifiedMatcher(Qu return result_matched_column_nodes_with_names; } -QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, JoinKind join_kind, IdentifierResolveScope & scope); +QueryTreeNodePtr createProjectionForUsing(const ColumnNode & using_column_node, JoinKind join_kind, IdentifierResolveScope & scope, std::optional preserved_side); /// Resolve non qualified matcher, using scope join tree node. QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher(QueryTreeNodePtr & matcher_node, IdentifierResolveScope & scope) @@ -2685,6 +2699,18 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( QueryTreeNodesWithNames matched_expression_nodes_with_column_names; + /** For SEMI/ANTI JOIN, SELECT * should only return columns from one side per SQL standard: + * - LEFT SEMI/ANTI JOIN: only left table columns + * - RIGHT SEMI/ANTI JOIN: only right table columns + * Controlled by `semi_join_compatibility` and `anti_join_compatibility` (see `SemiAntiJoinSideChecker`). + */ + SemiAntiJoinSideChecker semi_anti_star_checker( + *join_node, + join_node->getStrictness(), + join_node->getKind(), + scope.context, + scope.resolving_join_on_expression); + /** If there is JOIN with USING we need to match only single USING column and do not use left table expression * and right table expression column with same name. * @@ -2732,7 +2758,8 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( is_column_from_parent_scope(join_using_column_nodes.at(1))) continue; - QueryTreeNodePtr matched_column_node = createProjectionForUsing(join_using_column_node, join_node->getKind(), scope); + QueryTreeNodePtr matched_column_node = createProjectionForUsing( + join_using_column_node, join_node->getKind(), scope, semi_anti_star_checker.preservedSideOrNone()); matched_column_node->setAlias(join_using_column_name); table_expression_column_names_to_skip.insert(join_using_column_name); @@ -2740,18 +2767,6 @@ QueryAnalyzer::QueryTreeNodesWithNames QueryAnalyzer::resolveUnqualifiedMatcher( } } - /** For SEMI/ANTI JOIN, SELECT * should only return columns from one side per SQL standard: - * - LEFT SEMI/ANTI JOIN: only left table columns - * - RIGHT SEMI/ANTI JOIN: only right table columns - * Controlled by `semi_join_compatibility` and `anti_join_compatibility` (see `SemiAntiJoinSideChecker`). - */ - SemiAntiJoinSideChecker semi_anti_star_checker( - *join_node, - join_node->getStrictness(), - join_node->getKind(), - scope.context, - scope.resolving_join_on_expression); - if (!semi_anti_star_checker.shouldSkipSide(JoinTableSide::Left)) { for (auto && left_table_column_with_name : left_table_expression_columns) diff --git a/src/Planner/PlannerJoinsLogical.cpp b/src/Planner/PlannerJoinsLogical.cpp index 1d64aee2bc9f..3f690e567be7 100644 --- a/src/Planner/PlannerJoinsLogical.cpp +++ b/src/Planner/PlannerJoinsLogical.cpp @@ -76,6 +76,8 @@ namespace Setting extern const SettingsBool allow_general_join_planning; extern const SettingsBool query_plan_display_internal_aliases; extern const SettingsJoinAlgorithm join_algorithm; + extern const SettingsBool semi_join_compatibility; + extern const SettingsBool anti_join_compatibility; } static const ActionsDAG::Node * appendExpression( @@ -204,6 +206,21 @@ buildJoinUsingCondition(const QueryTreeNodePtr & node, JoinOperatorBuildContext std::unordered_map changed_types; + /** When `semi_join_compatibility` / `anti_join_compatibility` hides one side of the JOIN, only the + * preserved side is part of the result, so its key column must keep its own type there instead of + * being widened to the `USING` supertype - the supertype is derived from the hidden side as well. + * Only the output type is kept: the join condition below still reads the supertype cast. + */ + const auto & join_query_settings = builder_context.planner_context->getQueryContext()->getSettingsRef(); + const bool hides_non_preserved_side + = (join_operator.strictness == JoinStrictness::Semi && join_query_settings[Setting::semi_join_compatibility]) + || (join_operator.strictness == JoinStrictness::Anti && join_query_settings[Setting::anti_join_compatibility]); + std::optional preserved_side; + if (hides_non_preserved_side && isLeft(join_operator.kind)) + preserved_side = JoinTableSide::Left; + else if (hides_non_preserved_side && isRight(join_operator.kind)) + preserved_side = JoinTableSide::Right; + JoinActionRef::AddFunction using_concat_function(FunctionFactory::instance().get("firstNonDefault", nullptr)); for (size_t i = 0; i < num_nodes; ++i) { @@ -226,10 +243,16 @@ buildJoinUsingCondition(const QueryTreeNodePtr & node, JoinOperatorBuildContext return arg; }; - for (const auto & inner_column : inner_columns) + for (size_t inner_column_index = 0; inner_column_index < inner_columns.size(); ++inner_column_index) { + const auto & inner_column = inner_columns[inner_column_index]; + /// The last inner column is the one from the right table expression, the rest are from the left. + const auto inner_column_side + = (inner_column_index + 1 == inner_columns.size()) ? JoinTableSide::Right : JoinTableSide::Left; + const bool keep_original_type = preserved_side == inner_column_side; + auto & arg = args.emplace_back(builder_context.addExpression(inner_column)); - if (!arg.getType()->equals(*result_type)) + if (!arg.getType()->equals(*result_type) && !keep_original_type) { arg = JoinActionRef::transform({arg}, cast_to_super); } diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference index 8327c9cfe0e7..fc175056a097 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.reference @@ -57,3 +57,50 @@ t1.a t2.a a t2.a 1 2 2 +UInt8 +UInt8 UInt8 +id +UInt8 +1 +id +UInt8 +1 +id +UInt8 +1 +id +UInt8 +1 +id +UInt8 +2 +id +UInt8 +1 +a b +UInt8 UInt8 +1 2 +id +Int8 +1 +id +Int8 +-1 +2 +id +UInt8 +1 +id +Nullable(UInt8) +1 +id +LowCardinality(UInt8) +1 +UInt8 +UInt16 +id +UInt16 +2 +id +UInt16 +1 diff --git a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql index 2cd7ba73ad3c..5cbaa04f87f8 100644 --- a/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql +++ b/tests/queries/0_stateless/03913_semi_anti_join_column_access.sql @@ -390,3 +390,42 @@ SET semi_join_compatibility = 0; SELECT * FROM (SELECT 1 AS a) AS t1 RIGHT SEMI JOIN (SELECT 2 AS a) AS t2 ON true FORMAT TSVWithNames; SELECT t2.a FROM (SELECT * FROM (SELECT 1 AS a) AS t1 RIGHT SEMI JOIN (SELECT 2 AS a) AS t2 ON true); SET semi_join_compatibility = 1; + +SET semi_join_compatibility = 1, anti_join_compatibility = 1; +-- A `USING` key of the preserved side must keep its own type: the `USING` supertype is derived from +-- the hidden side too, so exposing it would leak the type of a table that is not part of the result. +SELECT toTypeName(id) FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id); +SELECT toTypeName(id), toTypeName(l.id) FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id); +SELECT * FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +SELECT l.* FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +SELECT id FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +-- The preserved side of a RIGHT SEMI JOIN is the right one. +SELECT * FROM (SELECT toUInt16(1) AS id) AS l RIGHT SEMI JOIN (SELECT toUInt8(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +-- The same for ANTI JOIN, controlled by `anti_join_compatibility`. +SELECT * FROM (SELECT toUInt8(2) AS id) AS l LEFT ANTI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +SELECT * FROM (SELECT toUInt16(2) AS id) AS l RIGHT ANTI JOIN (SELECT toUInt8(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +-- Every `USING` key of a multi-column clause is kept on the preserved side. +SELECT * FROM (SELECT toUInt8(1) AS a, toUInt8(2) AS b) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS a, toInt64(2) AS b) AS r USING (a, b) FORMAT TSVWithNamesAndTypes; +-- Only the result type changes - the rows are the ones the default behaviour returns, including +-- signed/unsigned keys that must not compare equal after a narrowing conversion. +DROP TABLE IF EXISTS t_semi_using_left; +DROP TABLE IF EXISTS t_semi_using_right; +CREATE TABLE t_semi_using_left (id Int8) ENGINE = Memory; +INSERT INTO t_semi_using_left VALUES (-1), (1), (2); +CREATE TABLE t_semi_using_right (id UInt8) ENGINE = Memory; +INSERT INTO t_semi_using_right VALUES (255), (1), (3); +SELECT id FROM t_semi_using_left LEFT SEMI JOIN t_semi_using_right USING (id) ORDER BY id FORMAT TSVWithNamesAndTypes; +SELECT id FROM t_semi_using_left LEFT ANTI JOIN t_semi_using_right USING (id) ORDER BY id FORMAT TSVWithNamesAndTypes; +SELECT id FROM t_semi_using_left RIGHT SEMI JOIN t_semi_using_right USING (id) ORDER BY id FORMAT TSVWithNamesAndTypes; +-- Nullability and LowCardinality of the preserved side are kept as well. +SELECT id FROM (SELECT toNullable(toUInt8(1)) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +SELECT id FROM (SELECT toLowCardinality(toUInt8(1)) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) SETTINGS join_use_nulls = 1 FORMAT TSVWithNamesAndTypes; +-- The nested-subquery shape resolves the bare name and keeps the preserved-side type. +SELECT toTypeName(id) FROM (SELECT * FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id)); +-- With the compatibility settings disabled, the `USING` supertype is exposed as before. +SELECT toTypeName(id) FROM (SELECT toUInt8(1) AS id) AS l LEFT SEMI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) SETTINGS semi_join_compatibility = 0; +SELECT * FROM (SELECT toUInt8(2) AS id) AS l LEFT ANTI JOIN (SELECT toUInt16(1) AS id) AS r USING (id) SETTINGS anti_join_compatibility = 0 FORMAT TSVWithNamesAndTypes; +-- A plain LEFT JOIN is unaffected by these settings. +SELECT * FROM (SELECT toUInt8(1) AS id) AS l LEFT JOIN (SELECT toUInt16(1) AS id) AS r USING (id) FORMAT TSVWithNamesAndTypes; +DROP TABLE t_semi_using_left; +DROP TABLE t_semi_using_right; From d3a0019425f40a4eb7a0e13510b2298a4e890b9a Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Mon, 21 Sep 2026 21:20:20 +0300 Subject: [PATCH 101/272] Write `processors_profile_log` entries for queries that fail with an exception Until now `processors_profile_log` was written only from the query finish path, so queries ending with `ExceptionWhileProcessing` (for example `MEMORY_LIMIT_EXCEPTION`) had no entries. `BlockIO::onException` now passes the pipeline, which is not reset yet, to the exception callbacks, and the callback from `executeQuery` logs its processors. `LocalConnection` kept its executors alive during `onException`, so their threads could still write the processors' counters. It now stops them first, as `finishQuery` does before `onFinish`. Related: https://github.com/ClickHouse/ClickHouse/pull/106263 --- src/Client/LocalConnection.cpp | 5 +++++ src/Interpreters/executeQuery.cpp | 5 ++++- src/QueryPipeline/BlockIO.cpp | 2 +- src/QueryPipeline/BlockIO.h | 3 ++- ...cessors_profile_log_on_exception.reference | 1 + ...35_processors_profile_log_on_exception.sql | 22 +++++++++++++++++++ 6 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference create mode 100644 tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql diff --git a/src/Client/LocalConnection.cpp b/src/Client/LocalConnection.cpp index 1e6f6e8d78cd..8c53d2ffd896 100644 --- a/src/Client/LocalConnection.cpp +++ b/src/Client/LocalConnection.cpp @@ -153,6 +153,11 @@ void LocalConnection::sendProfileEvents() void LocalConnection::captureCurrentException() { + /// Stop the executors before `onException`, as `finishQuery` does before `onFinish`: + /// `onException` logs the processors' profile counters, which the executor threads write. + state->executor.reset(); + state->pushing_async_executor.reset(); + state->pushing_executor.reset(); state->io.onException(); try { diff --git a/src/Interpreters/executeQuery.cpp b/src/Interpreters/executeQuery.cpp index 180eaa14315e..60f0e6a87580 100644 --- a/src/Interpreters/executeQuery.cpp +++ b/src/Interpreters/executeQuery.cpp @@ -3360,7 +3360,7 @@ static BlockIO executeQueryImpl( }; auto exception_callback = - [start_watch, elem, context, out_ast, internal, log_as_internal, my_quota(quota), normalized_query_hash, implicit_tcl_executor, query_span](bool log_error) mutable + [start_watch, elem, context, out_ast, internal, log_as_internal, my_quota(quota), normalized_query_hash, implicit_tcl_executor, query_span](bool log_error, const QueryPipeline & query_pipeline) mutable { if (implicit_tcl_executor->transactionRunning()) { @@ -3379,6 +3379,9 @@ static BlockIO executeQueryImpl( } logQueryException(elem, context, start_watch, out_ast, query_span, internal, log_as_internal, log_error); + + if (query_pipeline.initialized()) + logProcessorProfile(context, query_pipeline.getProcessors()); }; res.finalize_query_pipeline = std::move(finish_callback_finalize_pipeline); diff --git a/src/QueryPipeline/BlockIO.cpp b/src/QueryPipeline/BlockIO.cpp index d5e4d88cf5c0..98e445adde16 100644 --- a/src/QueryPipeline/BlockIO.cpp +++ b/src/QueryPipeline/BlockIO.cpp @@ -111,7 +111,7 @@ void BlockIO::onException(bool log_as_error) setAllDataSent(); for (const auto & callback : exception_callbacks) - callback(log_as_error); + callback(log_as_error, pipeline); /// Stop the pipeline before releasing workload resources: pipeline threads hold raw /// pointers to `MemoryReservation` and call `syncWithMemoryTracker` between processors. diff --git a/src/QueryPipeline/BlockIO.h b/src/QueryPipeline/BlockIO.h index 7a2191083a9b..db62ba5d145f 100644 --- a/src/QueryPipeline/BlockIO.h +++ b/src/QueryPipeline/BlockIO.h @@ -44,7 +44,8 @@ struct BlockIO std::function finalize_query_pipeline; VectorWithMemoryTracking> finish_callbacks; - VectorWithMemoryTracking> exception_callbacks; + /// Called with `log_as_error` and the pipeline, which is not reset yet, so the callback can log its processors. + VectorWithMemoryTracking> exception_callbacks; /// When it is true, don't bother sending any non-empty blocks to the out stream bool null_format = false; diff --git a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference new file mode 100644 index 000000000000..9972842f9827 --- /dev/null +++ b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference @@ -0,0 +1 @@ +1 1 diff --git a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql new file mode 100644 index 000000000000..42267356ebc3 --- /dev/null +++ b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql @@ -0,0 +1,22 @@ +-- `processors_profile_log` must have entries for a query that fails during execution. + +SET log_processors_profiles = 1, log_queries = 1; + +SELECT throwIf(number = 3, 'processors_profile_log_on_exception') FROM numbers(10) FORMAT Null; -- { serverError FUNCTION_THROW_IF_VALUE_IS_NON_ZERO } + +SYSTEM FLUSH LOGS query_log, processors_profile_log; + +WITH + ( + SELECT query_id + FROM system.query_log + WHERE event_date >= yesterday() + AND current_database = currentDatabase() + AND type = 'ExceptionWhileProcessing' + AND query LIKE 'SELECT throwIf(number = 3, \'processors_profile_log_on_exception\')%' + ORDER BY event_time_microseconds DESC + LIMIT 1 + ) AS failed_query_id +SELECT count() > 0, countIf(output_rows > 0) > 0 +FROM system.processors_profile_log +WHERE event_date >= yesterday() AND query_id = failed_query_id; From 8d93af37debf44d361747b2811da00e6253e5800 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Mon, 21 Sep 2026 21:26:03 +0300 Subject: [PATCH 102/272] Do not throw `MEMORY_LIMIT_EXCEEDED` while logging processors of a failed query A query that failed because of the memory limit still holds its pipeline's memory when the exception callback runs. The allocations in `logProcessorProfile` could then throw again, replace the original exception and skip the pipeline reset in `BlockIO::onException`. --- src/Interpreters/executeQuery.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Interpreters/executeQuery.cpp b/src/Interpreters/executeQuery.cpp index 60f0e6a87580..280f5583f5ec 100644 --- a/src/Interpreters/executeQuery.cpp +++ b/src/Interpreters/executeQuery.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -3381,7 +3382,12 @@ static BlockIO executeQueryImpl( logQueryException(elem, context, start_watch, out_ast, query_span, internal, log_as_internal, log_error); if (query_pipeline.initialized()) + { + /// The query may have failed because of the memory limit, and the pipeline still holds its memory. + /// A second `MEMORY_LIMIT_EXCEEDED` here would replace the original exception and skip the pipeline reset. + LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Global); logProcessorProfile(context, query_pipeline.getProcessors()); + } }; res.finalize_query_pipeline = std::move(finish_callback_finalize_pipeline); From 405472b7490bd4f12dbdab73b98e86590a1b1b13 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Mon, 21 Sep 2026 20:36:01 +0000 Subject: [PATCH 103/272] Address review: forward `supportsHardLinks` through `ReadOnlyDiskWrapper` `ReadOnlyDiskWrapper` forwards the capabilities of the disk it wraps (`isWriteOnce`, `supportZeroCopyReplication`, `supportsCache`, ...), but not `supportsHardLinks`, so it fell back to `IDisk::supportsHardLinks`, which is `true`. A read-only attachment of a disk that cannot share blobs - a `plain_rewritable` disk without `enable_hard_links`, and before this pull request also any `plain` or write-once disk - therefore reported that the operations needing hard links are possible, while the disk it wraps reported the opposite. `ALTER` and mutations do not reach the capability check on such a table: `InterpreterAlterQuery` rejects it earlier with `TABLE_IS_PERMANENTLY_READ_ONLY`. The reachable caller is `InterpreterHypotheticalObjectQuery`, which mirrors the `ALTER` rule because a hypothetical index is only worth estimating on a table that could really get one. The new test `05234_read_only_disk_hard_links_capability` creates a read-only attachment of a `plain_rewritable` disk over the directory of a live table and checks that `CREATE HYPOTHETICAL INDEX` on it is refused with the same reason as on the writable table, and that with `enable_hard_links` both accept it - so the wrapper is not answering `false` unconditionally either. Without the fix the first arm accepts the index that the very same disk refuses. Co-Authored-By: Claude Opus 5 (1M context) --- src/Disks/ReadOnlyDiskWrapper.h | 4 + ..._only_disk_hard_links_capability.reference | 8 ++ ...34_read_only_disk_hard_links_capability.sh | 88 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.reference create mode 100755 tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh diff --git a/src/Disks/ReadOnlyDiskWrapper.h b/src/Disks/ReadOnlyDiskWrapper.h index 784d84b0655c..f8a2a45b080f 100644 --- a/src/Disks/ReadOnlyDiskWrapper.h +++ b/src/Disks/ReadOnlyDiskWrapper.h @@ -70,6 +70,10 @@ public: bool isRemote() const override { return delegate->isRemote(); } bool isWriteOnce() const override { return delegate->isWriteOnce(); } + /// Forwarded, although a hard link is never created through this wrapper: the callers ask the disk of a table + /// whether an operation that needs hard links is possible at all, and a read-only attachment of a disk that + /// cannot share blobs must give the same answer as the disk itself. + bool supportsHardLinks() const override { return delegate->supportsHardLinks(); } bool supportZeroCopyReplication() const override { return delegate->supportZeroCopyReplication(); } bool supportParallelWrite() const override { return delegate->supportParallelWrite(); } SyncGuardPtr getDirectorySyncGuard(const String & path) const override { return delegate->getDirectorySyncGuard(path); } diff --git a/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.reference b/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.reference new file mode 100644 index 000000000000..5789fa8434c1 --- /dev/null +++ b/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.reference @@ -0,0 +1,8 @@ +-- the read-only reader sees the data +1 Hello +-- without enable_hard_links the read-only attachment reports the capability of the disk it wraps +Hypothetical indexes are not supported on immutable disk +-- the writer of the same disk answers the same way +Hypothetical indexes are not supported on immutable disk +-- with enable_hard_links the read-only attachment accepts it +h reader_hard_links diff --git a/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh b/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh new file mode 100755 index 000000000000..baf74f836a2a --- /dev/null +++ b/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Tags: no-object-storage, no-replicated-database, no-shared-merge-tree +# Tag no-object-storage: the test defines its own disks +# Tag no-replicated-database: plain rewritable should not be shared between replicas, and hypothetical indexes are session-scoped +# Tag no-shared-merge-tree: does not support replication + +# A disk created with `read_only = true` is wrapped in `ReadOnlyDiskWrapper`, which has to report the +# capabilities of the disk it wraps. `supportsHardLinks` decides whether the operations that need hard +# links are offered at all, so a read-only attachment of a `plain_rewritable` disk without +# `enable_hard_links` must refuse them for the same reason as the disk itself. `ALTER` and mutations are +# rejected earlier on a read-only table, so the check is reachable through `CREATE HYPOTHETICAL INDEX`, +# which mirrors it because an index can only be added to a table whose parts can be hard-linked. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +disk_path="disks/05234/${CLICKHOUSE_DATABASE}/" +disk_path_with_hard_links="disks/05234_hard_links/${CLICKHOUSE_DATABASE}/" + +${CLICKHOUSE_CLIENT} -m --query " +DROP TABLE IF EXISTS writer SYNC; +DROP TABLE IF EXISTS reader SYNC; + +CREATE TABLE writer (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk( + name = '05234_writer_${CLICKHOUSE_DATABASE}', + type = object_storage, object_storage_type = local, metadata_type = plain_rewritable, + path = '${disk_path}'); + +INSERT INTO writer VALUES (1, 'Hello'); + +CREATE TABLE reader (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk( + read_only = true, + name = '05234_reader_${CLICKHOUSE_DATABASE}', + type = object_storage, object_storage_type = local, metadata_type = plain_rewritable, + path = '${disk_path}'); +" + +echo '-- the read-only reader sees the data' +${CLICKHOUSE_CLIENT} --query "SELECT * FROM reader" + +echo '-- without enable_hard_links the read-only attachment reports the capability of the disk it wraps' +${CLICKHOUSE_CLIENT} --query "CREATE HYPOTHETICAL INDEX h ON reader value TYPE minmax GRANULARITY 1" 2>&1 | + grep -o -m1 'Hypothetical indexes are not supported on immutable disk' + +echo '-- the writer of the same disk answers the same way' +${CLICKHOUSE_CLIENT} --query "CREATE HYPOTHETICAL INDEX h ON writer value TYPE minmax GRANULARITY 1" 2>&1 | + grep -o -m1 'Hypothetical indexes are not supported on immutable disk' + +# The wrapper must not answer `false` unconditionally either: with the setting on, the read-only +# attachment accepts what the disk it wraps accepts. +${CLICKHOUSE_CLIENT} -m --query " +DROP TABLE IF EXISTS writer_hard_links SYNC; +DROP TABLE IF EXISTS reader_hard_links SYNC; + +CREATE TABLE writer_hard_links (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk( + name = '05234_writer_hl_${CLICKHOUSE_DATABASE}', + type = object_storage, object_storage_type = local, metadata_type = plain_rewritable, + enable_hard_links = 1, + path = '${disk_path_with_hard_links}'); + +INSERT INTO writer_hard_links VALUES (1, 'Hello'); + +CREATE TABLE reader_hard_links (key Int32, value String) ENGINE = MergeTree ORDER BY key +SETTINGS table_disk = 1, disk = disk( + read_only = true, + name = '05234_reader_hl_${CLICKHOUSE_DATABASE}', + type = object_storage, object_storage_type = local, metadata_type = plain_rewritable, + enable_hard_links = 1, + path = '${disk_path_with_hard_links}'); +" + +echo '-- with enable_hard_links the read-only attachment accepts it' +# Hypothetical indexes are session-scoped, so the definition and the readback share one invocation. +${CLICKHOUSE_CLIENT} -m --query " +CREATE HYPOTHETICAL INDEX h ON reader_hard_links value TYPE minmax GRANULARITY 1; +SELECT name, table FROM system.hypothetical_indexes ORDER BY table; +" + +${CLICKHOUSE_CLIENT} -m --query " +DROP TABLE reader SYNC; +DROP TABLE writer SYNC; +DROP TABLE reader_hard_links SYNC; +DROP TABLE writer_hard_links SYNC; +" From f1cd2ee406e84389d8ad23dbecbb6c53e944fc82 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Mon, 21 Sep 2026 20:36:10 +0000 Subject: [PATCH 104/272] Address review: do not name a release in the downgrade note of `enable_hard_links` The note said that servers older than `26.9` cannot read a directory with an explicit file list, which is a number this pull request cannot back: `26.9` is already branched off and master is `26.10`, so the boundary would be wrong even if the re-land lands today, and wrong downgrade guidance either blocks a safe downgrade or invites an unsupported mixed-version setup. The boundary is stated in terms of the feature instead - a server that does not have the `enable_hard_links` setting is exactly a server that cannot read the form, because reading it was added together with the setting. This is the wording already used next to the code, in `MetadataStorageFromPlainRewritableObjectStorage.h`. Co-Authored-By: Claude Opus 5 (1M context) --- .../configuration/server-config/storing-data.mdx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/concepts/features/configuration/server-config/storing-data.mdx b/docs/concepts/features/configuration/server-config/storing-data.mdx index e3126c1bd8cf..73b75455361d 100644 --- a/docs/concepts/features/configuration/server-config/storing-data.mdx +++ b/docs/concepts/features/configuration/server-config/storing-data.mdx @@ -470,12 +470,14 @@ with the keys of the objects storing them, so that a file can refer to an object stored under the prefix of another directory. Consider the following before enabling it: -- Servers older than `26.9` cannot read a directory in this form. A disk that has - such directories is not readable after a downgrade, and cannot be shared with - older servers. Directories that never had hard links keep the previous layout, - so a disk stays readable by older servers until the first operation that - creates a hard link - a mutation, an `ALTER`, a `FREEZE` or a backup. Turning - the setting off again does not convert the existing directories back. +- A server that does not have the `enable_hard_links` setting cannot read a + directory in this form - it reads the whole metadata of the directory as its + logical path. A disk that has such directories is therefore not readable after + a downgrade to such a version, and cannot be shared with it. Directories that + never had hard links keep the previous layout, so a disk stays readable by + older servers until the first operation that creates a hard link - a mutation, + an `ALTER`, a `FREEZE` or a backup. Turning the setting off again does not + convert the existing directories back. - An object whose last link is removed is deleted in two steps: the metadata of the directory is rewritten first, and the object is deleted after that. If the server is killed in between, the object is left in the bucket and is not From 639dfbca6d2c89a7c76d0887c1b84943ba677cee Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Tue, 22 Sep 2026 17:02:33 +0300 Subject: [PATCH 105/272] Address review: exception columns, `initialized` check, HTTP test - Add `exception_code` and `exception` columns to `system.processors_profile_log`. - Check that `processors` is set in `QueryPipeline::initialized`: a moved-from pipeline has none. - Do not block the global memory tracker while logging the processors. - Pass the settings in the query itself and add a test over HTTP. --- src/Interpreters/ProcessorsProfileLog.cpp | 17 ++++++++++++++--- src/Interpreters/ProcessorsProfileLog.h | 13 +++++++++++-- src/Interpreters/executeQuery.cpp | 8 +------- src/QueryPipeline/QueryPipeline.h | 2 +- ...cessors_profile_log_on_exception.reference | 2 +- ...35_processors_profile_log_on_exception.sql | 9 +++++---- ...rs_profile_log_on_exception_http.reference | 2 ++ ...rocessors_profile_log_on_exception_http.sh | 19 +++++++++++++++++++ 8 files changed, 54 insertions(+), 18 deletions(-) create mode 100644 tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.reference create mode 100755 tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.sh diff --git a/src/Interpreters/ProcessorsProfileLog.cpp b/src/Interpreters/ProcessorsProfileLog.cpp index 665330ca38b5..deae2d6b534b 100644 --- a/src/Interpreters/ProcessorsProfileLog.cpp +++ b/src/Interpreters/ProcessorsProfileLog.cpp @@ -58,6 +58,8 @@ ColumnsDescription ProcessorProfileLogElement::getColumnsDescription() {"output_bytes", std::make_shared(), "The number of bytes generated by processor."}, {"processor_uniq_id", std::make_shared(), "The uniq processor id in pipeline."}, {"step_uniq_id", std::make_shared(), "The uniq step id in plan."}, + {"exception_code", std::make_shared(), "Code of the exception if the query failed, otherwise 0."}, + {"exception", std::make_shared(), "Message of the exception if the query failed, otherwise empty."}, }; } @@ -97,6 +99,8 @@ void ProcessorProfileLogElement::appendToBlock(MutableColumns & columns) const columns[i++]->insert(output_bytes); columns[i++]->insert(processor_uniq_id); columns[i++]->insert(step_uniq_id); + columns[i++]->insert(exception_code); + columns[i++]->insertData(exception.data(), exception.size()); } VectorWithMemoryTracking getProcessorsProfileLogInfo(const Processors & processors) @@ -112,7 +116,12 @@ VectorWithMemoryTracking getProcessorsProfileLogInfo(c return infos; } -void logProcessorProfile(ContextPtr context, const VectorWithMemoryTracking & profile_infos, String pipeline_dump) +void logProcessorProfile( + ContextPtr context, + const VectorWithMemoryTracking & profile_infos, + String pipeline_dump, + Int32 exception_code, + const String & exception) { const Settings & settings = context->getSettingsRef(); if (settings[Setting::log_processors_profiles]) @@ -126,6 +135,8 @@ void logProcessorProfile(ContextPtr context, const VectorWithMemoryTrackinggetInitialQueryId(); processor_elem.query_id = context->getCurrentQueryId(); + processor_elem.exception_code = exception_code; + processor_elem.exception = exception; for (const auto & info : profile_infos) { @@ -158,7 +169,7 @@ void logProcessorProfile(ContextPtr context, const VectorWithMemoryTracking getProcessorsProfileLogInfo(const Processors & processors); -void logProcessorProfile(ContextPtr context, const Processors & processors); -void logProcessorProfile(ContextPtr context, const VectorWithMemoryTracking & profile_infos, String pipeline_dump); +void logProcessorProfile(ContextPtr context, const Processors & processors, Int32 exception_code = 0, const String & exception = {}); +void logProcessorProfile( + ContextPtr context, + const VectorWithMemoryTracking & profile_infos, + String pipeline_dump, + Int32 exception_code = 0, + const String & exception = {}); } diff --git a/src/Interpreters/executeQuery.cpp b/src/Interpreters/executeQuery.cpp index 280f5583f5ec..9ab5921c6d99 100644 --- a/src/Interpreters/executeQuery.cpp +++ b/src/Interpreters/executeQuery.cpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -3382,12 +3381,7 @@ static BlockIO executeQueryImpl( logQueryException(elem, context, start_watch, out_ast, query_span, internal, log_as_internal, log_error); if (query_pipeline.initialized()) - { - /// The query may have failed because of the memory limit, and the pipeline still holds its memory. - /// A second `MEMORY_LIMIT_EXCEEDED` here would replace the original exception and skip the pipeline reset. - LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Global); - logProcessorProfile(context, query_pipeline.getProcessors()); - } + logProcessorProfile(context, query_pipeline.getProcessors(), elem.exception_code, elem.exception); }; res.finalize_query_pipeline = std::move(finish_callback_finalize_pipeline); diff --git a/src/QueryPipeline/QueryPipeline.h b/src/QueryPipeline/QueryPipeline.h index 59b6ca47d1a4..cf6be7e64ced 100644 --- a/src/QueryPipeline/QueryPipeline.h +++ b/src/QueryPipeline/QueryPipeline.h @@ -93,7 +93,7 @@ public: OutputPort * totals_ = nullptr, OutputPort * extremes_ = nullptr); - bool initialized() const { return !processors->empty(); } + bool initialized() const { return processors && !processors->empty(); } /// When initialized, exactly one of the following is true. /// Use PullingPipelineExecutor or PullingAsyncPipelineExecutor. bool pulling() const { return output != nullptr; } diff --git a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference index 9972842f9827..72749c905a31 100644 --- a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference +++ b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.reference @@ -1 +1 @@ -1 1 +1 1 1 diff --git a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql index 42267356ebc3..b21418580b7e 100644 --- a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql +++ b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql @@ -1,8 +1,6 @@ -- `processors_profile_log` must have entries for a query that fails during execution. -SET log_processors_profiles = 1, log_queries = 1; - -SELECT throwIf(number = 3, 'processors_profile_log_on_exception') FROM numbers(10) FORMAT Null; -- { serverError FUNCTION_THROW_IF_VALUE_IS_NON_ZERO } +SELECT throwIf(number = 3, 'processors_profile_log_on_exception') FROM numbers(10) FORMAT Null SETTINGS log_processors_profiles = 1; -- { serverError FUNCTION_THROW_IF_VALUE_IS_NON_ZERO } SYSTEM FLUSH LOGS query_log, processors_profile_log; @@ -17,6 +15,9 @@ WITH ORDER BY event_time_microseconds DESC LIMIT 1 ) AS failed_query_id -SELECT count() > 0, countIf(output_rows > 0) > 0 +SELECT + count() > 0, + countIf(output_rows > 0) > 0, + countIf(exception_code = 395 AND exception LIKE '%processors_profile_log_on_exception%') = count() FROM system.processors_profile_log WHERE event_date >= yesterday() AND query_id = failed_query_id; diff --git a/tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.reference b/tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.reference new file mode 100644 index 000000000000..dbfe803f244f --- /dev/null +++ b/tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.reference @@ -0,0 +1,2 @@ +FUNCTION_THROW_IF_VALUE_IS_NON_ZERO +1 1 1 diff --git a/tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.sh b/tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.sh new file mode 100755 index 000000000000..952d1b38e1ca --- /dev/null +++ b/tests/queries/0_stateless/05236_processors_profile_log_on_exception_http.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# `processors_profile_log` must have entries for a query that fails during execution over HTTP. + +CURDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CURDIR"/../shell_config.sh + +QUERY_ID="${CLICKHOUSE_DATABASE}_processors_profile_log_on_exception_http" + +${CLICKHOUSE_CURL} -sS "${CLICKHOUSE_URL}&query_id=${QUERY_ID}&log_processors_profiles=1" \ + -d "SELECT throwIf(number = 3) FROM numbers(10) FORMAT Null" | grep -o 'FUNCTION_THROW_IF_VALUE_IS_NON_ZERO' | head -1 + +${CLICKHOUSE_CLIENT} -q "SYSTEM FLUSH LOGS processors_profile_log" + +${CLICKHOUSE_CLIENT} -q " + SELECT count() > 0, countIf(output_rows > 0) > 0, countIf(exception_code = 395) = count() + FROM system.processors_profile_log + WHERE event_date >= yesterday() AND query_id = '${QUERY_ID}'" From 70c63b5c02c8b036d9ed6aa262135e2546350f50 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Tue, 22 Sep 2026 19:42:34 +0300 Subject: [PATCH 106/272] Fix `05235_processors_profile_log_on_exception`: the query text starts with the comment The client sends the leading comment together with the first query, so `query_log` has it at the start of the query text and the prefix match found nothing. https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=121503&sha=639dfbca6d2c89a7c76d0887c1b84943ba677cee&name_0=PR&name_1=Fast%20test https://github.com/ClickHouse/ClickHouse/pull/121503 --- .../0_stateless/05235_processors_profile_log_on_exception.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql index b21418580b7e..3f4fdc397f73 100644 --- a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql +++ b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql @@ -11,7 +11,7 @@ WITH WHERE event_date >= yesterday() AND current_database = currentDatabase() AND type = 'ExceptionWhileProcessing' - AND query LIKE 'SELECT throwIf(number = 3, \'processors_profile_log_on_exception\')%' + AND query LIKE '%SELECT throwIf(number = 3, \'processors_profile_log_on_exception\')%' ORDER BY event_time_microseconds DESC LIMIT 1 ) AS failed_query_id From 066218e895a09aff1c95cf6b74fe1abf7bcd3d94 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Tue, 22 Sep 2026 23:32:43 +0300 Subject: [PATCH 107/272] Find the failed query by `log_comment` in the test --- .../0_stateless/05235_processors_profile_log_on_exception.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql index 3f4fdc397f73..0f8831e17ae6 100644 --- a/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql +++ b/tests/queries/0_stateless/05235_processors_profile_log_on_exception.sql @@ -11,7 +11,7 @@ WITH WHERE event_date >= yesterday() AND current_database = currentDatabase() AND type = 'ExceptionWhileProcessing' - AND query LIKE '%SELECT throwIf(number = 3, \'processors_profile_log_on_exception\')%' + AND log_comment = '05235_processors_profile_log_on_exception.sql-' || currentDatabase() ORDER BY event_time_microseconds DESC LIMIT 1 ) AS failed_query_id From c09752ef73bd56d08ab96ddbf5b5c37a3b5abc3e Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Wed, 23 Sep 2026 02:33:20 +0300 Subject: [PATCH 108/272] Own a processor before connecting its ports `QueryPipeline` connected a new processor's ports and only then put it into `processors`. The `emplace_back` in between allocates, so it can throw, and then the processor is destroyed while the pipeline keeps ports pointing into it. `04299_move_assignment_memory_limit` injects a memory tracker failure into an arbitrary allocation and hit exactly this. Logging the processors of a failed query then read the destroyed ports and reported `Port does not belong to Processor`. https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=121503&sha=066218e895a09aff1c95cf6b74fe1abf7bcd3d94&name_0=PR&name_1=Stateless%20tests%20(arm_binary,%20parallel) https://github.com/ClickHouse/ClickHouse/pull/121503 --- src/QueryPipeline/QueryPipeline.cpp | 55 ++++++++++++++++++----------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src/QueryPipeline/QueryPipeline.cpp b/src/QueryPipeline/QueryPipeline.cpp index 16d781d14979..48a8b17a8a7e 100644 --- a/src/QueryPipeline/QueryPipeline.cpp +++ b/src/QueryPipeline/QueryPipeline.cpp @@ -502,16 +502,15 @@ QueryPipeline::QueryPipeline(std::shared_ptr format) processors->emplace_back(std::move(source)); } + output_format = format.get(); + processors->emplace_back(std::move(format)); + connect(*totals, format_totals); connect(*extremes, format_extremes); input = &format_main; totals = nullptr; extremes = nullptr; - - output_format = format.get(); - - processors->emplace_back(std::move(format)); } /// Discards `totals`/`extremes` without adding a childless node; see `DroppingTransform`. @@ -530,22 +529,26 @@ static void dropTotalsAndExtremesViaTransform( totals ? totals->getSharedHeader() : nullptr, extremes ? extremes->getSharedHeader() : nullptr); - connect(*output, dropping->getInputs().front()); + /// Put the processor into the pipeline before connecting it: `emplace_back` allocates and can throw, + /// and a processor that is connected but not owned leaves the pipeline with ports into a destroyed object. + auto * dropping_ptr = dropping.get(); + processors.emplace_back(std::move(dropping)); + + connect(*output, dropping_ptr->getInputs().front()); if (totals) { - connect(*totals, *dropping->getTotalsPort()); + connect(*totals, *dropping_ptr->getTotalsPort()); totals = nullptr; } if (extremes) { - connect(*extremes, *dropping->getExtremesPort()); + connect(*extremes, *dropping_ptr->getExtremesPort()); extremes = nullptr; } - output = &dropping->getOutputs().front(); - processors.emplace_back(std::move(dropping)); + output = &dropping_ptr->getOutputs().front(); } QueryPipeline::QueryPipeline(std::shared_ptr sink) : QueryPipeline(Chain(std::move(sink))) {} @@ -557,8 +560,10 @@ void QueryPipeline::complete(std::shared_ptr sink) dropTotalsAndExtremesViaTransform(output, totals, extremes, *processors); - connect(*output, sink->getPort()); + auto * sink_ptr = sink.get(); processors->emplace_back(std::move(sink)); + + connect(*output, sink_ptr->getPort()); output = nullptr; } @@ -575,9 +580,11 @@ void QueryPipeline::complete(Chain chain) processors->emplace_back(std::move(processor)); auto sink = std::make_shared(chain.getOutputPort().getSharedHeader()); - connect(*output, chain.getInputPort()); - connect(chain.getOutputPort(), sink->getPort()); + auto * sink_ptr = sink.get(); processors->emplace_back(std::move(sink)); + + connect(*output, chain.getInputPort()); + connect(chain.getOutputPort(), sink_ptr->getPort()); output = nullptr; } @@ -593,11 +600,13 @@ void QueryPipeline::complete(Pipe pipe) pipe.resize(1); pipe.dropTotalsAndExtremes(); - connect(*pipe.getOutputPort(0), *input); - input = nullptr; + auto * pipe_output = pipe.getOutputPort(0); auto pipe_processors = Pipe::detachProcessors(std::move(pipe)); processors->insert(processors->end(), pipe_processors.begin(), pipe_processors.end()); + + connect(*pipe_output, *input); + input = nullptr; } static void addMaterializing(OutputPort *& output, Processors & processors, bool remove_special_column_representations) @@ -606,9 +615,11 @@ static void addMaterializing(OutputPort *& output, Processors & processors, bool return; auto materializing = std::make_shared(output->getSharedHeader(), remove_special_column_representations); - connect(*output, materializing->getInputPort()); - output = &materializing->getOutputPort(); + auto * materializing_ptr = materializing.get(); processors.emplace_back(std::move(materializing)); + + connect(*output, materializing_ptr->getInputPort()); + output = &materializing_ptr->getOutputPort(); } void QueryPipeline::complete(std::shared_ptr format) @@ -642,6 +653,10 @@ void QueryPipeline::complete(std::shared_ptr format) processors->emplace_back(std::move(source)); } + /// Own the format before connecting it, see the comment in `dropTotalsAndExtremesViaTransform`. + auto * format_ptr = format.get(); + processors->emplace_back(std::move(format)); + connect(*output, format_main); connect(*totals, format_totals); connect(*extremes, format_extremes); @@ -650,18 +665,16 @@ void QueryPipeline::complete(std::shared_ptr format) totals = nullptr; extremes = nullptr; - initRowsBeforeLimit(format.get()); + initRowsBeforeLimit(format_ptr); for (const auto & context : resources.interpreter_context) { if (context->getSettingsRef()[Setting::rows_before_aggregation]) { - initRowsBeforeAggregation(processors, format.get()); + initRowsBeforeAggregation(processors, format_ptr); break; } } - output_format = format.get(); - - processors->emplace_back(std::move(format)); + output_format = format_ptr; } Block QueryPipeline::getHeader() const From 02a63b225894fd952342cc79720c066078c348a8 Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Wed, 23 Sep 2026 08:55:05 +0300 Subject: [PATCH 109/272] Address the AI review: the rest of the pipeline, and the query memory limit - Own the processor before connecting it in the remaining places of `QueryPipeline`: the `Chain` constructor, `setLimitsAndQuota`, the query result cache transforms and sources, and `addExpression`. - Block `MEMORY_LIMIT_EXCEEDED` from the query's own tracker while logging the processors of a failed query. Otherwise the new exception replaces the original one and `BlockIO::onException` never resets the pipeline. The lock is at `VariableContext::Process`, so the server-wide and per-user limits still apply. --- src/Interpreters/executeQuery.cpp | 7 +++++++ src/QueryPipeline/QueryPipeline.cpp | 26 ++++++++++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Interpreters/executeQuery.cpp b/src/Interpreters/executeQuery.cpp index 9ab5921c6d99..2030893dc503 100644 --- a/src/Interpreters/executeQuery.cpp +++ b/src/Interpreters/executeQuery.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -3381,7 +3382,13 @@ static BlockIO executeQueryImpl( logQueryException(elem, context, start_watch, out_ast, query_span, internal, log_as_internal, log_error); if (query_pipeline.initialized()) + { + /// The query may have failed because of its own memory limit, and its pipeline still holds the memory. + /// Throwing `MEMORY_LIMIT_EXCEEDED` again here would replace the original exception and skip the + /// pipeline reset in `BlockIO::onException`. The server-wide and per-user limits still apply. + LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Process); logProcessorProfile(context, query_pipeline.getProcessors(), elem.exception_code, elem.exception); + } }; res.finalize_query_pipeline = std::move(finish_callback_finalize_pipeline); diff --git a/src/QueryPipeline/QueryPipeline.cpp b/src/QueryPipeline/QueryPipeline.cpp index 48a8b17a8a7e..1c9028328c48 100644 --- a/src/QueryPipeline/QueryPipeline.cpp +++ b/src/QueryPipeline/QueryPipeline.cpp @@ -475,9 +475,11 @@ QueryPipeline::QueryPipeline(Chain chain) processors->emplace_back(std::move(processor)); auto sink = std::make_shared(chain.getOutputPort().getSharedHeader()); - connect(chain.getOutputPort(), sink->getPort()); + auto * sink_ptr = sink.get(); processors->emplace_back(std::move(sink)); + connect(chain.getOutputPort(), sink_ptr->getPort()); + input = &chain.getInputPort(); } @@ -728,9 +730,12 @@ void QueryPipeline::setLimitsAndQuota(const StreamLocalLimits & limits, std::sha auto transform = std::make_shared(output->getSharedHeader(), limits); transform->setQuota(quota_); transform->setNormalizedQueryHash(normalized_query_hash); - connect(*output, transform->getInputPort()); - output = &transform->getOutputPort(); + + auto * transform_ptr = transform.get(); processors->emplace_back(std::move(transform)); + + connect(*output, transform_ptr->getInputPort()); + output = &transform_ptr->getOutputPort(); } bool QueryPipeline::tryGetResultRowsAndBytes(UInt64 & result_rows, UInt64 & result_bytes) const @@ -763,9 +768,11 @@ void QueryPipeline::writeResultIntoQueryResultCache(std::shared_ptr(out_port->getHeader(), query_result_cache_writer, chunk_type); - connect(*out_port, transform->getInputPort()); - out_port = &transform->getOutputPort(); + auto * transform_ptr = transform.get(); processors->emplace_back(std::move(transform)); + + connect(*out_port, transform_ptr->getInputPort()); + out_port = &transform_ptr->getOutputPort(); }; using enum QueryResultCacheWriter::ChunkType; @@ -797,8 +804,9 @@ void QueryPipeline::readFromQueryResultCache( { if (!source_) return; - out_port = &source_->getPort(); + auto * source_ptr = source_.get(); processors->emplace_back(std::shared_ptr(std::move(source_))); + out_port = &source_ptr->getPort(); }; add_stream_from_query_result_cache_source(output, std::move(source)); @@ -850,9 +858,11 @@ static void addExpression(OutputPort *& port, ExpressionActionsPtr actions, Proc if (port) { auto transform = std::make_shared(port->getSharedHeader(), actions); - connect(*port, transform->getInputPort()); - port = &transform->getOutputPort(); + auto * transform_ptr = transform.get(); processors.emplace_back(std::move(transform)); + + connect(*port, transform_ptr->getInputPort()); + port = &transform_ptr->getOutputPort(); } } From 92567f0bfa1ef623aad1278c1ff43ee0477c4a4f Mon Sep 17 00:00:00 2001 From: Groene AI <270696204+groeneai@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:39:04 +0000 Subject: [PATCH 110/272] Report external DISTINCT in system.query_log.spilled_to_disk The column names the operators that processed data in external memory and documents that an empty array means the query ran fully in memory. A SELECT DISTINCT that spilled reported an empty array anyway, so the row made a false statement about that query. Aggregation, sort and both join spillers set their name, so DISTINCT was the only silent producer. DistinctStep::transformPipeline builds the external-DISTINCT temporary-data scope with the four ExternalDistinct* metric fields and leaves the fifth, TemporaryDataMetrics::spilled_to_disk_operator, at its default empty string_view. When the first bytes reach a temporary file, TemporaryDataBuffer calls QueryExecutionCounters::markSpilledToDisk with that name, and the function returns immediately when it is empty. The same call site increments the ExternalDistinct* profile events a few lines later, which is why the existing profile-event tests could not see this and only the column was wrong. Setting the field is the whole fix, and the shape matches SortingStep, which passes "sort" beside the identical four fields. Reporting from ExternalDistinctTransform instead was rejected: it would fire when a temporary file is created rather than when it is first written, which breaks the invariant that an operator creating files it never writes to is not reported. 05046_query_log_spilled_to_disk_needs_a_write pins that invariant and stays green here. The new test has three arms so it can only redden for one reason: a spilling DISTINCT, the same query with both thresholds at zero, and a spilling ORDER BY as a positive control against a regression that empties the whole column. Each arm also reads ProfileEvents['ExternalDistinctWritePart'] from the same query_log row, so a fixture that stops spilling is distinguished from a column that stops reporting. The deduplicated expression is number % 100000 rather than number, because DISTINCT over a prefix of the input order is deduplicated range by range and returns before the external path whatever optimize_distinct_in_order is set to, which is why the reproducer in the issue does not spill. Measured before and after on the same fixture: the spilling arm reported an empty array in 11 of 11 executions before and ['distinct'] in 95 of 95 after, with every counted row confirmed to have written temporary files, over both draws of optimize_distinct_in_order. 50 of 50 randomized runs pass at 0.24s each. The assertion is a new file rather than an addition to 04891_query_log_join_columns, which already runs up to 132.4s against the runner's 180s limit under asan while the flaky check repeats a changed test 50 times. Closes: https://github.com/ClickHouse/ClickHouse/issues/121792 Co-Authored-By: Claude Opus 5 --- src/Processors/QueryPlan/DistinctStep.cpp | 3 +- ...ery_log_spilled_to_disk_distinct.reference | 3 ++ ...243_query_log_spilled_to_disk_distinct.sql | 40 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.reference create mode 100644 tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.sql diff --git a/src/Processors/QueryPlan/DistinctStep.cpp b/src/Processors/QueryPlan/DistinctStep.cpp index fb77b0383b27..55ca666cadc6 100644 --- a/src/Processors/QueryPlan/DistinctStep.cpp +++ b/src/Processors/QueryPlan/DistinctStep.cpp @@ -244,7 +244,8 @@ void DistinctStep::transformPipeline(QueryPipelineBuilder & pipeline, const Buil {.current_metric = CurrentMetrics::TemporaryFilesForDistinct, .bytes_compressed = ProfileEvents::ExternalDistinctCompressedBytes, .bytes_uncompressed = ProfileEvents::ExternalDistinctUncompressedBytes, - .num_files = ProfileEvents::ExternalDistinctWritePart}, + .num_files = ProfileEvents::ExternalDistinctWritePart, + .spilled_to_disk_operator = "distinct"}, settings.temporary_files_buffer_size, settings.temporary_files_codec); diff --git a/tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.reference b/tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.reference new file mode 100644 index 000000000000..818bdfc9b707 --- /dev/null +++ b/tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.reference @@ -0,0 +1,3 @@ +05243_spill_a_distinct_spilled ['distinct'] 1 0 +05243_spill_b_distinct_in_memory [] 0 0 +05243_spill_c_sort_spilled ['sort'] 0 1 diff --git a/tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.sql b/tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.sql new file mode 100644 index 000000000000..215880ad1f66 --- /dev/null +++ b/tests/queries/0_stateless/05243_query_log_spilled_to_disk_distinct.sql @@ -0,0 +1,40 @@ +-- `system.query_log.spilled_to_disk` names the operators that wrote data to temporary files, and a +-- `DISTINCT` that runs out of its memory budget is one of them. The same `DISTINCT` with spilling +-- disabled must report nothing, and a spilling `ORDER BY` must still report itself, so neither a +-- value reported unconditionally nor a column that stopped reporting can pass. + +SET log_queries = 1; + +-- The one-byte budget with exact memory accounting and a pinned block size makes the spill +-- deterministic. The deduplicated expression must not follow the order of the input: a `DISTINCT` on +-- a prefix of the input order is deduplicated range by range and never needs temporary files. +SELECT DISTINCT number % 100000 AS k FROM numbers(300000) +FORMAT Null +SETTINGS log_comment = '05243_spill_a_distinct_spilled', max_bytes_before_external_distinct = 1, + max_bytes_ratio_before_external_distinct = 0, max_block_size = 65409, max_untracked_memory = 0; + +-- Both thresholds at zero disable spilling, so the same query writes nothing. +SELECT DISTINCT number % 100000 AS k FROM numbers(300000) +FORMAT Null +SETTINGS log_comment = '05243_spill_b_distinct_in_memory', max_bytes_before_external_distinct = 0, + max_bytes_ratio_before_external_distinct = 0; + +SELECT a FROM (SELECT number AS a FROM numbers(100000)) ORDER BY a +FORMAT Null +SETTINGS log_comment = '05243_spill_c_sort_spilled', max_bytes_before_external_sort = 100000, + max_bytes_ratio_before_external_sort = 0; + +SYSTEM FLUSH LOGS query_log; + +-- The temporary-file counters are read from the same rows, so a query that stopped spilling is told +-- apart from a column that stopped reporting. The second segment of the comment keeps the statements +-- that carry the runner's own per-test comment out of the result. +SELECT log_comment, spilled_to_disk, + ProfileEvents['ExternalDistinctWritePart'] > 0 AS distinct_wrote_temporary_files, + ProfileEvents['ExternalSortWritePart'] > 0 AS sort_wrote_temporary_files +FROM system.query_log +WHERE current_database = currentDatabase() + AND type = 'QueryFinish' + AND event_date >= yesterday() + AND log_comment LIKE '05243\_spill\_%' +ORDER BY log_comment; From 3d7ba9286fb46029abc3bdd1e3b486926142bca1 Mon Sep 17 00:00:00 2001 From: ClickGap Bot Date: Thu, 24 Sep 2026 13:18:39 +0000 Subject: [PATCH 111/272] Add database = currentDatabase() to 05218 system table queries Style check requires the predicate on every test naming system.parts / system.projection_parts; all four query sites in 05218_system_projection_parts_unknown_codec.sh now carry it. Requested by @rienath --- .../05218_system_projection_parts_unknown_codec.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh b/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh index a93295290488..0ba54b1139e3 100755 --- a/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh +++ b/tests/queries/0_stateless/05218_system_projection_parts_unknown_codec.sh @@ -18,19 +18,19 @@ SETTINGS default_compression_codec = 'ZSTD(3)', min_bytes_for_wide_part = 0, min INSERT INTO tab SELECT number, toString(number) FROM numbers(1000); " -part_path=$($CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT path FROM system.parts WHERE table = 'tab' AND active ORDER BY name") +part_path=$($CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT path FROM system.parts WHERE database = currentDatabase() AND table = 'tab' AND active ORDER BY name") echo '-- the codec of the projection part is known' -$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, default_compression_codec FROM system.projection_parts WHERE table = 'tab' AND active ORDER BY name" +$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, default_compression_codec FROM system.projection_parts WHERE database = currentDatabase() AND table = 'tab' AND active ORDER BY name" rm "${part_path:?}p.proj/default_compression_codec.txt" echo '-- codec file gone: the codec recovered from the projection data is reported as unknown' -$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, default_compression_codec FROM system.projection_parts WHERE table = 'tab' AND active ORDER BY name" +$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, default_compression_codec FROM system.projection_parts WHERE database = currentDatabase() AND table = 'tab' AND active ORDER BY name" rm -rf "${part_path:?}p.proj" echo '-- projection part could not be loaded: broken, with no codec' -$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, empty(default_compression_codec) FROM system.projection_parts WHERE table = 'tab' AND active ORDER BY name" +$CLICKHOUSE_LOCAL --path "$data_path" -q "SELECT name, is_broken, empty(default_compression_codec) FROM system.projection_parts WHERE database = currentDatabase() AND table = 'tab' AND active ORDER BY name" rm -rf "${data_path:?}" From 49bef87df13283d4277a6c7ebfcb4c2aee8d47e1 Mon Sep 17 00:00:00 2001 From: Actuele AI <323385743+actueleai@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:58:13 +0000 Subject: [PATCH 112/272] Bump `libcotp` from v4.2.1 to v4.2.2 Upstream release v4.2.2 hardens the base32, OTP-counter and otpauth/YAOTP URI paths: counters are now encoded to 8 big-endian bytes independently of `sizeof(long)` and host endianness, `whmac_setkey` releases a previously set MAC context instead of leaking it, a reused `secret=` URI parameter is zeroed before being freed, and a truncated percent-escape is rejected instead of being taken literally. The generated OTP codes are unchanged; the URI-parsing tightening is not reachable from ClickHouse, which uses only `get_totp_at`. No ClickHouse-side source or CMake changes were needed: the source list in `contrib/libcotp-cmake/CMakeLists.txt` is still complete (the new files live under `fuzz/` and `tests/`), and the `cotp_error_t` enum and the public API are unchanged. Co-Authored-By: Claude Code --- contrib/libcotp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/libcotp b/contrib/libcotp index 3a7fa1a78071..2ffa6281ff42 160000 --- a/contrib/libcotp +++ b/contrib/libcotp @@ -1 +1 @@ -Subproject commit 3a7fa1a780716534e800ba51f80fe929af77adf7 +Subproject commit 2ffa6281ff4219636354df08b12c1a5aadd12ab4 From 22120b39e8935afcd948742d3c285eb8623ae52b Mon Sep 17 00:00:00 2001 From: Groene AI <270696204+groeneai@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:42:53 +0000 Subject: [PATCH 113/272] Bound the vector search limit of mergeTreeAnalyzeIndexes by max_limit_for_vector_search_queries The bound on a vector search limit is a plan-optimization precondition rather than a property of VectorSearchParameters, and the introspection table function builds those parameters itself without re-establishing it. useVectorSearch.cpp returns no_layers_updated when the LIMIT exceeds max_limit_for_vector_search_queries (default 1000), so a regular ORDER BY LIMIT 2147483648 never reaches the approximate search at all and falls back to brute force. That gate is the only thing keeping the value small: ReadFromMergeTree::setVectorSearchParameters is called from behind it, and the remote query that distributed index analysis formats can therefore only carry a limit that already passed it. TableFunctionMergeTreeAnalyzeIndexes is the second producer of the same struct, and it validates the type of the limit parameter without ever comparing it against the setting, so a fuzzer-sized value reached usearch unchanged. The one bound inside the index is conditional on additional_filters_present or on vector_search_with_rescoring, both false by default, so it did not apply either. usearch then reserves eight bytes per requested neighbour twice per granule searched (index.hpp computes expansion as max(config.expansion, wanted) and reserves both the next and the top candidate buffers). At 2147483648 neighbours that is 16.0 GiB per reservation and 32 GiB per granule search, taken through a raw aligned_alloc whose pages are never touched. On a server the memory tracker therefore charges 32 GiB while RSS stays at a few hundred MiB, the tracker is exhausted, and every later session fails with "(total) memory limit exceeded" including the hung check's own client. Measured on a debug server capped at 9.31 GiB: the query returned a normal result, tracked memory settled at 32.31 GiB against an RSS of 400 MiB, and the two stateless tests that ran next could not so much as create their database, both failing with code 241; afterwards even SELECT value FROM system.metrics failed the same way, so one SELECT leaves the server unable to answer anything. With this change the same query is rejected before any reservation and both of those tests pass. The check goes where the value enters, mirroring the planner's comparison exactly so that the introspection function accepts precisely the set of limits for which the optimization it reports on exists. It is a sixth per-parameter helper beside the five that already validate this argument list, so the error names the parameter from the same table they use, and the braced initializer keeps its left-to-right validation order: a list with several malformed parameters still reports the first of them rather than the limit. Both entry points share parseArgumentsForOptimizations, so mergeTreeAnalyzeIndexes and mergeTreeAnalyzeIndexesUUID are covered by the one comparison, and it runs before executeImpl, before any part is read and before anything is reserved. Making the in-index clamp unconditional instead would have let an introspection function report the ranges for limit 1000 when asked about 2147483648, and after this change both producers of VectorSearchParameters are bounded anyway. A raised max_limit_for_vector_search_queries still reaches a remote node with the query, so a legitimate distributed vector search cannot trip the new check: over a real remote connection a limit of 2000 succeeds at max_limit_for_vector_search_queries = 5000 and is rejected at the default 1000. Reported by alexey-milovidov on #112824 from a Stress test (arm_release) hung check, where the AST fuzzer mutated 02354_vector_search_distributed_index_analysis into mergeTreeAnalyzeIndexesUUID(..., array('vec', toFixedString('L2Distance', 10), 2147483648, ...)). --- .../TableFunctionMergeTreeAnalyzeIndexes.cpp | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/TableFunctions/TableFunctionMergeTreeAnalyzeIndexes.cpp b/src/TableFunctions/TableFunctionMergeTreeAnalyzeIndexes.cpp index 8ea18b0af37b..7d861cd770d4 100644 --- a/src/TableFunctions/TableFunctionMergeTreeAnalyzeIndexes.cpp +++ b/src/TableFunctions/TableFunctionMergeTreeAnalyzeIndexes.cpp @@ -3,10 +3,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -40,6 +42,11 @@ const char * mergeTreeAnalyzeIndexFunctionName(bool resolve_by_uuid) namespace DB { +namespace Setting +{ + extern const SettingsUInt64 max_limit_for_vector_search_queries; +} + namespace ErrorCodes { extern const int BAD_ARGUMENTS; @@ -160,6 +167,18 @@ static UInt64 getVectorSearchUnsignedArgument(const Array & args, size_t index) throwBadVectorSearchArgument(field, index, "a non-negative integer"); } +/// The plan optimization refuses a larger limit before building these parameters for a regular vector search. +static UInt64 getVectorSearchLimitArgument(const Array & args, size_t index, UInt64 max_limit) +{ + UInt64 limit = getVectorSearchUnsignedArgument(args, index); + if (limit > max_limit) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "Parameter #{} ({}) of the 'vector_search_index_analysis' optimization is {}, which exceeds " + "setting 'max_limit_for_vector_search_queries' ({})", + index + 1, vector_search_parameter_names[index], limit, max_limit); + return limit; +} + /// `buildAnalyzeIndexQuery` formats the flags as `true` / `false`, a hand-written list is likely to use `1` / `0`. static bool getVectorSearchBoolArgument(const Array & args, size_t index) { @@ -347,7 +366,8 @@ void TableFunctionMergeTreeAnalyzeIndexes::parseArgumentsForOptimizations(const vector_search_parameters = VectorSearchParameters{ getVectorSearchStringArgument(vector_search_args, 0), getVectorSearchStringArgument(vector_search_args, 1), - getVectorSearchUnsignedArgument(vector_search_args, 2), + getVectorSearchLimitArgument(vector_search_args, 2, + context->getSettingsRef()[Setting::max_limit_for_vector_search_queries]), getVectorSearchReferenceVector(vector_search_args, 3), getVectorSearchBoolArgument(vector_search_args, 4), getVectorSearchBoolArgument(vector_search_args, 5)}; From 0b0017e23cca4ead68f62130a02417d0427a3135 Mon Sep 17 00:00:00 2001 From: Groene AI <270696204+groeneai@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:43:11 +0000 Subject: [PATCH 114/272] Add a regression test asserting the analysed ranges and bracketing the cap The in-bound arms select the analysed ranges rather than counting rows. A count is true even when vector index analysis produced nothing, because StorageMergeTreeAnalyzeIndexes also emits a row for every source part the analysis did not cover, with a default-constructed ranges column, so such an arm would only assert that the call did not throw and a change that made an in-bound limit silently stop using the index would keep it green. On this fixture a limit of 4 gives all_1_1_0 [(3,5)], two of the part's thirteen granules, where a limit large enough to cover every row gives [(0,13)], which is also what the unbounded call returns on a binary without the fix. The assertion therefore observes the narrowing rather than the absence of an exception. The exact ranges are stable because the table fixes index_granularity itself, which beats the test runner's randomized value: fifty randomized runs produced no reference mismatch. One arm sets the limit equal to the cap, immediately above the arm that rejects a limit one above its cap, so the pair pins both directions of the comparison rather than only its far side. This matters beyond style. Both plan optimizations gate with a strict comparison, so a regular vector search whose LIMIT is exactly the cap is rewritten, and distributed index analysis serializes that limit verbatim into the replica-side mergeTreeAnalyzeIndexesUUID query; a non-strict comparison here would fail such a query on every replica while succeeding on the initiator, and at the default cap of 1000 that is a plausible production LIMIT. Verified by mutation: rebuilding with a non-strict comparison reddens this arm and only this arm. Both boolean parameters are left false and vector_search_with_rescoring is left at its default, because those two conditions are what engage the separate, pre-existing bound inside the index, which would hide a missing bound here. --- ..._search_index_analysis_limit_cap.reference | 2 + ...vector_search_index_analysis_limit_cap.sql | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.reference create mode 100644 tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.sql diff --git a/tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.reference b/tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.reference new file mode 100644 index 000000000000..e49fdf446471 --- /dev/null +++ b/tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.reference @@ -0,0 +1,2 @@ +all_1_1_0 [(3,5)] +all_1_1_0 [(3,5)] diff --git a/tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.sql b/tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.sql new file mode 100644 index 000000000000..15aea4fb7bf6 --- /dev/null +++ b/tests/queries/0_stateless/05257_vector_search_index_analysis_limit_cap.sql @@ -0,0 +1,40 @@ +-- Tags: no-fasttest + +-- Tests that `mergeTreeAnalyzeIndexes` rejects a 'vector_search_index_analysis' neighbour count above +-- `max_limit_for_vector_search_queries`, the bound under which a regular vector search query may use the index. +-- Such a count used to be handed to the vector similarity index unchanged, which then reserved memory +-- proportional to it (32 GiB for the count below) instead of to the number of rows in the index. + +DROP TABLE IF EXISTS t_vector_index_analysis_limit; + +CREATE TABLE t_vector_index_analysis_limit +( + id UInt32, + vec Array(Float32), + INDEX idx_vec vec TYPE vector_similarity('hnsw', 'L2Distance', 2) +) +ENGINE = MergeTree ORDER BY id SETTINGS index_granularity = 8; + +INSERT INTO t_vector_index_analysis_limit SELECT number, [number / 100, number / 100] FROM numbers(100); + +-- `additional filters present` (the 5th argument) is false and `vector_search_with_rescoring` is left +-- at its default: either one engages a separate, pre-existing bound on the neighbour count, which would +-- hide a missing bound here. +SELECT * FROM mergeTreeAnalyzeIndexes(currentDatabase(), t_vector_index_analysis_limit, 1, [], + 'vector_search_index_analysis', array('vec', 'L2Distance', 2147483648, [0.3, 0.3], false, false)); -- { serverError BAD_ARGUMENTS } + +-- A neighbour count within the bound is still analyzed, and the index still narrows the part. +SELECT * FROM mergeTreeAnalyzeIndexes(currentDatabase(), t_vector_index_analysis_limit, 1, [], + 'vector_search_index_analysis', array('vec', 'L2Distance', 4, [0.3, 0.3], false, false)); + +-- The bound is the setting, and it is inclusive: the planner uses the index at a LIMIT equal to the +-- setting, so this function must accept exactly that. +SELECT * FROM mergeTreeAnalyzeIndexes(currentDatabase(), t_vector_index_analysis_limit, 1, [], + 'vector_search_index_analysis', array('vec', 'L2Distance', 4, [0.3, 0.3], false, false)) +SETTINGS max_limit_for_vector_search_queries = 4; + +SELECT * FROM mergeTreeAnalyzeIndexes(currentDatabase(), t_vector_index_analysis_limit, 1, [], + 'vector_search_index_analysis', array('vec', 'L2Distance', 4, [0.3, 0.3], false, false)) +SETTINGS max_limit_for_vector_search_queries = 3; -- { serverError BAD_ARGUMENTS } + +DROP TABLE t_vector_index_analysis_limit; From 6263af97ed3279d2eaaa98a3e3d068172dcdcd72 Mon Sep 17 00:00:00 2001 From: bisht2050 <108942387+bisht2050@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:22:52 +0100 Subject: [PATCH 115/272] Identify ClickHouse in the MongoDB driver handshake Append "ClickHouse" and the server version to the client metadata that mongocxx sends in the initial `hello` handshake, using the public `mongocxx::client::append_metadata` API available since r4.5.0 (the submodule was bumped to r4.6.0 in #119346). MongoDB server operators can now tell ClickHouse connections apart in mongod/mongos logs, `currentOp` output and monitoring tools: the driver reports itself as "mongoc / mongocxx / ClickHouse" with the matching versions. Adds an integration test that reads the handshake metadata mongod logs for each new connection and checks the ClickHouse name and version. Follow-up to #113327, split out of the driver upgrade as requested. Co-authored-by: Alex Bevilacqua --- src/Processors/Sources/MongoDBSource.cpp | 19 ++++++++- .../integration/test_storage_mongodb/test.py | 42 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/Processors/Sources/MongoDBSource.cpp b/src/Processors/Sources/MongoDBSource.cpp index e5993dde6597..1a305c6fc5aa 100644 --- a/src/Processors/Sources/MongoDBSource.cpp +++ b/src/Processors/Sources/MongoDBSource.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -152,6 +153,22 @@ void MongoDBSource::insertValue(IColumn & column, const size_t & idx, const Data } } +namespace +{ + +/// Identify ClickHouse in the MongoDB connection handshake, so that server operators can tell +/// ClickHouse connections apart from other clients in mongod/mongos logs, `currentOp` output +/// and monitoring tools (the driver reports itself as "mongoc / mongocxx / ClickHouse"). +/// See https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.md#wrapping-libraries +mongocxx::client createClient(const mongocxx::uri & uri) +{ + mongocxx::client client{uri}; + /// The metadata is sent with the initial `hello` command, so it has to be appended before the first operation. + client.append_metadata("ClickHouse", VERSION_STRING); + return client; +} + +} MongoDBSource::MongoDBSource( const mongocxx::uri & uri, @@ -161,7 +178,7 @@ MongoDBSource::MongoDBSource( SharedHeader sample_block_, const UInt64 & max_block_size_) : ISource{std::make_shared(sample_block_->cloneEmpty())} - , client{uri} + , client{createClient(uri)} , database{client.database(uri.database())} , collection{database.collection(collection_name)} , cursor{collection.find(query, options)} diff --git a/tests/integration/test_storage_mongodb/test.py b/tests/integration/test_storage_mongodb/test.py index 08e0cfd3ef2a..167a357e40fb 100644 --- a/tests/integration/test_storage_mongodb/test.py +++ b/tests/integration/test_storage_mongodb/test.py @@ -1496,4 +1496,44 @@ def test_url_validation(started_cluster): f"CREATE OR REPLACE TABLE url_validation_table(key UInt64, data String) ENGINE = MongoDB('mongo1', 'test', 'url_validation_table', 'root@aa.com', '{mongo_pass}')" ) - assert node.query("SELECT COUNT() FROM url_validation_table") == "100\n" \ No newline at end of file + assert node.query("SELECT COUNT() FROM url_validation_table") == "100\n" + + +def test_handshake_metadata(started_cluster): + # ClickHouse identifies itself in the MongoDB connection handshake as a wrapping library + # (https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.md#wrapping-libraries), + # so that MongoDB server operators can tell ClickHouse connections apart in mongod/mongos logs. + mongo_connection = get_mongo_connection(started_cluster) + db = mongo_connection["test"] + db.command("dropAllUsersFromDatabase") + db.command("createUser", "root", pwd=mongo_pass, roles=["readWrite"]) + drop_mongo_collection_if_exists(db, "handshake_metadata_table") + handshake_metadata_table = db["handshake_metadata_table"] + handshake_metadata_table.insert_many([{"key": i} for i in range(0, 10)]) + + node = started_cluster.instances["node"] + clickhouse_version = node.query("SELECT version()").strip() + + node.query( + f"CREATE OR REPLACE TABLE handshake_metadata_table(key UInt64) ENGINE = MongoDB('mongo1', 'test', 'handshake_metadata_table', 'root', '{mongo_pass}')" + ) + assert node.query("SELECT COUNT() FROM handshake_metadata_table") == "10\n" + + # mongod logs the metadata received in the handshake of every new connection as a JSON line + # with `"msg": "client metadata"`. Other connections (e.g. from pymongo) are logged too, hence the sets. + driver_names = set() + driver_versions = set() + for line in started_cluster.get_container_logs("mongo1").splitlines(): + if '"client metadata"' not in line: + continue + driver = json.loads(line)["attr"]["client"]["driver"] + driver_names.add(driver["name"]) + driver_versions.add(driver["version"]) + + assert "mongoc / mongocxx / ClickHouse" in driver_names, driver_names + assert any( + version.endswith(f" / {clickhouse_version}") for version in driver_versions + ), driver_versions + + node.query("DROP TABLE handshake_metadata_table") + handshake_metadata_table.drop() From 3a25632fe767ad87d57e2d3057ea0c091f3c86e8 Mon Sep 17 00:00:00 2001 From: Groene AI <270696204+groeneai@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:12:29 +0000 Subject: [PATCH 116/272] Do not read one byte past the value in parseDateTimeBestEffort parseDateTimeBestEffortImpl dereferences in.position() in the num_digits == 0 branch with no eof() check. The enclosing while (!in.eof()) loop only guarantees a readable byte at the top of an iteration, and the alphabetical-month branch both leaves num_digits == 0 and can consume the last byte of the value, so a value such as '1-Jan' reaches that read with the position already at the end of the buffer. Since 0bd3c2401a166 ("Remove zero byte", #85063) ColumnString no longer stores a trailing terminator, so getDataAt() returns a span ending exactly at the value. The byte read is therefore the first byte of the next row, or the uninitialized right padding of the chars array when the value is the last row. When that byte is one of ' ', 'Z', '.', '+' or '-', the code advances the position past working_buffer.end(); BufferBase::available() is an unclamped size_t(end - pos), so it wraps, eof() never becomes true again, and the parser keeps consuming the neighbouring rows. The consequence differs by caller. The conversion family re-checks isAllRead(), so parseDateTimeBestEffort*, parseDateTime64BestEffort* and toDateTime with date_time_input_format = 'best_effort' reject a valid value (NULL, zero, or CANNOT_PARSE_TEXT). addMonths and the rest of the add-interval family over a String argument do not re-check, so they return date and time fields taken from the following row. Guarding the read with eof() restores the control flow the terminator used to produce: '\0' fell through to readAlpha, which returned zero characters and broke out of the loop. eof() attempts a refill first, so a value straddling a chunk boundary in a refillable ReadBuffer keeps parsing; a plain pointer comparison would have stopped early there. Reported by the memory sanitizer as use-of-uninitialized-value src/IO/parseDateTimeBestEffort.cpp:703:26 in Unit tests (msan, function_prop_fuzzer) on PR #116042 (914a5397) and PR #118721 (906e40f1), and at the same column in 2024 as issue #61771. PR #61694 added this same guard to the comma branch of this loop; the num_digits == 0 branch was not part of that change. Related: #61771 --- src/IO/parseDateTimeBestEffort.cpp | 3 ++ ...te_time_best_effort_row_boundary.reference | 9 ++++++ ...rse_date_time_best_effort_row_boundary.sql | 31 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.reference create mode 100644 tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.sql diff --git a/src/IO/parseDateTimeBestEffort.cpp b/src/IO/parseDateTimeBestEffort.cpp index 1fb167e7267d..fa31fd3f6fb2 100644 --- a/src/IO/parseDateTimeBestEffort.cpp +++ b/src/IO/parseDateTimeBestEffort.cpp @@ -697,6 +697,9 @@ ReturnType parseDateTimeBestEffortImpl( if (num_digits == 0) { + if (in.eof()) + break; + char c = *in.position(); /// 'T' is a separator between date and time according to ISO 8601. diff --git a/tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.reference b/tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.reference new file mode 100644 index 000000000000..8ee5f8fbb839 --- /dev/null +++ b/tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.reference @@ -0,0 +1,9 @@ +1-Jan 0 1 1 1 0 0 0 + 12:00:00 0 1 1 1 12 0 0 +1-Jan 1 2 1 0 + 2020-01-02 03:04:05 0 2 2 3 +2020-01-02 03:04:05 +1-Jan 1 1 0 +2020-01-02 03:04:05 1 2 3 +2020-01-01 00:00:00 +1 diff --git a/tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.sql b/tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.sql new file mode 100644 index 000000000000..ccf14a3d08af --- /dev/null +++ b/tests/queries/0_stateless/05257_parse_date_time_best_effort_row_boundary.sql @@ -0,0 +1,31 @@ +-- The parsed value of a row must not depend on the text of the neighbouring row of the same column. +-- A value without a year gets the current year substituted, so the year is asserted relative to +-- today() and the remaining fields absolutely; that keeps the reference stable across calendar years. + +-- A valid '1-Jan' must be accepted even though the next row starts with a space. +SELECT s, isNull(v) AS rejected, + toYear(v) = toYear(today()) AS year_is_current, + toMonth(v) AS m, toDayOfMonth(v) AS d, + toHour(v) AS h, toMinute(v) AS mi, toSecond(v) AS sec +FROM (SELECT s, parseDateTimeBestEffortOrNull(s) AS v + FROM (SELECT arrayJoin(['1-Jan', ' 12:00:00']) AS s)); + +-- addMonths over a String does not reject a partially parsed value, so on this surface the +-- neighbouring row leaks into the result instead of the value being rejected. +SELECT s, toYear(t) = toYear(today()) AS year_is_current, + toMonth(t) AS m, toDayOfMonth(t) AS d, toHour(t) AS h +FROM (SELECT s, addMonths(s, 1) AS t + FROM (SELECT arrayJoin(['1-Jan', ' 2020-01-02 03:04:05']) AS s)); + +-- Positive controls: a canonical value, a neighbour that cannot extend the value, and an +-- alphabetical month that carries its own year, all of which were already correct. +SELECT parseDateTimeBestEffortOrNull('2020-01-02 03:04:05'); + +SELECT s, toMonth(v) AS m, toDayOfMonth(v) AS d, toHour(v) AS h +FROM (SELECT s, parseDateTimeBestEffortOrNull(s) AS v + FROM (SELECT arrayJoin(['1-Jan', '2020-01-02 03:04:05']) AS s)); + +SELECT parseDateTimeBestEffortOrNull('1-Jan-2020'); + +-- The last value of a column has no neighbour, so this is the arm the memory sanitizer observes. +SELECT parseDateTimeBestEffortOrNull(materialize(toFixedString('1-Jan', 5))) IS NOT NULL; From e25c3d4955dc0bfbeeafc2da1210f6fe2d8ed717 Mon Sep 17 00:00:00 2001 From: Groene AI <270696204+groeneai@users.noreply.github.com> Date: Sat, 26 Sep 2026 05:40:28 +0000 Subject: [PATCH 117/272] Tokenize each LowCardinality dictionary value once when building a text index Building a text index over a `LowCardinality(String)` column, or over the elements of an `Array(LowCardinality(String))` column, tokenized every row and hashed and looked up every token, although the values come from a small dictionary. Now `MergeTreeIndexAggregatorText::update` tokenizes a dictionary value at its first occurrence in the call, keeps pointers to the posting list builders of its tokens, and appends later rows directly to them. The builders live in the cells of the token map, which move when the map grows, so each cached value remembers the buffer size it was cached at and is cached again after a resize; a value whose own tokens made the map grow is not cached at all. The map only shrinks in `reset`, which is never called while `update` runs (the INSERT/merge transform flushes a segment after `update`, the part writer takes the granule before it). The cache is used only without a postprocessor drop filter (so no builder is `Filtered`) and when dictionary values repeat, at least 8 documents per dictionary value in the call (a conservative threshold): a value is tokenized at its first occurrence anyway, so without repeats there is nothing to save. Columns with ~30,000 and ~865,000 distinct values stay on the existing code and are unchanged. `addToken` and `addDocument` compile to the same instructions as before (checked on a non-ThinLTO release build). Pointers are taken with a `find` right after the existing `addToken`, rather than through a copy of `addToken` that returns the builder, so the token insertion logic stays in one place. A cache of the packed token keys, with one `find` per repeated token, was also measured: it gives -27% on the shape below, the pointer cache -35%. Measured with a non-ThinLTO release build (paired, 20 repetitions): `MATERIALIZE INDEX` of a text index on a `LowCardinality(String)` column with 50 distinct values over 2M rows 106 ms -> 70 ms (-35%); arrays of 500 LowCardinality values per row -18% for INSERT and MATERIALIZE; String and high-cardinality LowCardinality columns unchanged within the noise; peak memory unchanged. The analysis and the first version of the patch are by snelheidai in #122234. Co-Authored-By: Claude Opus 5.5 --- src/Storages/MergeTree/MergeTreeIndexText.cpp | 174 ++++++++++++ .../text_index_low_cardinality_build.xml | 26 ++ ...index_low_cardinality_dictionary.reference | 33 +++ ..._text_index_low_cardinality_dictionary.sql | 253 ++++++++++++++++++ 4 files changed, 486 insertions(+) create mode 100644 tests/performance/text_index_low_cardinality_build.xml create mode 100644 tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.reference create mode 100644 tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.sql diff --git a/src/Storages/MergeTree/MergeTreeIndexText.cpp b/src/Storages/MergeTree/MergeTreeIndexText.cpp index 737afd66da85..4cf2e3bac3d7 100644 --- a/src/Storages/MergeTree/MergeTreeIndexText.cpp +++ b/src/Storages/MergeTree/MergeTreeIndexText.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -1831,6 +1832,163 @@ void MergeTreeIndexTextGranuleBuilder::incrementCurrentRow() ++current_row; } +namespace +{ + +template +void callWithLowCardinalityIndexes(const ColumnLowCardinality & column, Func && func) +{ + const IColumn & indexes = column.getIndexes(); + switch (column.getSizeOfIndexType()) + { + case sizeof(UInt8): func(assert_cast(indexes).getData()); break; + case sizeof(UInt16): func(assert_cast(indexes).getData()); break; + case sizeof(UInt32): func(assert_cast(indexes).getData()); break; + case sizeof(UInt64): func(assert_cast(indexes).getData()); break; + default: throwUnexpectedLowCardinalityIndexType(column.getSizeOfIndexType()); + } +} + +/// Adds values of a LowCardinality column as documents. A dictionary value is tokenized at its first +/// occurrence only; later occurrences add the row to the cached posting list builders of its tokens. +class LowCardinalityDocumentsAdder +{ +public: + LowCardinalityDocumentsAdder( + MergeTreeIndexTextGranuleBuilder & builder_, const ColumnLowCardinality & column, const PostingListBuildContext & context_) + : builder(builder_) + , dictionary(column.getDictionary()) + , context(context_) + , values(dictionary.size()) + { + } + + static bool isApplicable(const MergeTreeIndexTextGranuleBuilder & builder, const ColumnLowCardinality & column, size_t num_documents) + { + static constexpr size_t min_documents_per_value = 8; + return !builder.postprocessor_drop_filter && column.getDictionary().size() * min_documents_per_value <= num_documents; + } + + ALWAYS_INLINE void add(size_t index) + { + Value & value = values[index]; + const size_t buffer_size = builder.tokens_map.getBufferSizeInCells(); + /// The builders live in the cells of the map, which move only when the map grows. + /// The map shrinks only in MergeTreeIndexTextGranuleBuilder::reset, which is never called inside `update`. + chassert(buffer_size >= value.buffer_size); + if (value.buffer_size != buffer_size) [[unlikely]] + { + addAndCache(index, value); + return; + } + + const auto row = static_cast(builder.current_row); + /// Without a postprocessor drop filter no builder is `Filtered`. + for (size_t i = value.begin; i < value.begin + value.size; ++i) + tokens[i].posting_list->add(row, tokens[i].position, context); + + builder.num_processed_tokens += value.size; + } + +private: + struct Value + { + size_t begin = 0; + size_t size = 0; + /// Buffer size of the map when the builders were taken, 0 if they are not taken. + size_t buffer_size = 0; + }; + + struct Token + { + PostingListBuilder * posting_list; + UInt32 position; + }; + + void addAndCache(size_t index, Value & value) + { + const size_t buffer_size = builder.tokens_map.getBufferSizeInCells(); + const size_t begin = tokens.size(); + + if (!dictionary.isNullAt(index)) + { + const std::string_view document = dictionary.getDataAt(index); + UInt32 token_position = 0; + forEachToken( + *builder.tokenizer, + document.data(), + document.size(), + [&](const char * token_start, size_t token_length) + { + builder.addToken({token_start, token_length}, token_position, context); + auto * it = builder.tokens_map.find(PackedStringRef::build(token_start, token_length, PackedStringRefHash{})); + chassert(it); + tokens.push_back({&it->getMapped(), token_position}); + ++token_position; + return false; + }); + } + + /// A resize moves the builders taken before it, so they are taken again at the next occurrence. + if (builder.tokens_map.getBufferSizeInCells() == buffer_size) + { + value = {begin, tokens.size() - begin, buffer_size}; + } + else + { + tokens.resize(begin); + value.buffer_size = 0; + } + } + + MergeTreeIndexTextGranuleBuilder & builder; + const IColumnUnique & dictionary; + const PostingListBuildContext & context; + std::vector values; + std::vector tokens; +}; + +NO_INLINE void addLowCardinalityDocuments( + MergeTreeIndexTextGranuleBuilder & builder, + const ColumnLowCardinality & column, + size_t start_row, + size_t rows_read, + const PostingListBuildContext & context) +{ + LowCardinalityDocumentsAdder adder(builder, column, context); + callWithLowCardinalityIndexes(column, [&](const auto & indexes) + { + for (size_t i = start_row; i < start_row + rows_read; ++i) + { + adder.add(indexes[i]); + builder.incrementCurrentRow(); + } + }); +} + +/// Every array element is a separate document, so token positions restart from 0 in each element. +NO_INLINE void addLowCardinalityArrayDocuments( + MergeTreeIndexTextGranuleBuilder & builder, + const ColumnLowCardinality & data, + const IColumn::Offsets & offsets, + size_t start_row, + size_t rows_read, + const PostingListBuildContext & context) +{ + LowCardinalityDocumentsAdder adder(builder, data, context); + callWithLowCardinalityIndexes(data, [&](const auto & indexes) + { + for (size_t i = start_row; i < start_row + rows_read; ++i) + { + for (size_t element_idx = offsets[i - 1]; element_idx < offsets[i]; ++element_idx) + adder.add(indexes[element_idx]); + builder.incrementCurrentRow(); + } + }); +} + +} + std::unique_ptr MergeTreeIndexTextGranuleBuilder::build() { SortedTokens sorted_tokens; @@ -1939,6 +2097,11 @@ void MergeTreeIndexAggregatorText::update(const Block & block, size_t * pos, siz { addDocumentsFromMap(preprocessed_column, offset, rows_read, context); } + else if (const auto * column_low_cardinality = typeid_cast(preprocessed_column.get()); + column_low_cardinality && LowCardinalityDocumentsAdder::isApplicable(granule_builder, *column_low_cardinality, rows_read)) + { + addLowCardinalityDocuments(granule_builder, *column_low_cardinality, offset, rows_read, context); + } else { const bool column_is_nullable = isColumnNullableOrLowCardinalityNullable(*preprocessed_column); @@ -1965,6 +2128,17 @@ void MergeTreeIndexAggregatorText::addDocumentsFromArray(ColumnPtr column, size_ const IColumn::Offsets & column_offsets = column_array->getOffsets(); const bool data_is_nullable = isColumnNullableOrLowCardinalityNullable(column_data); + if constexpr (tokenize) + { + const size_t num_elements = column_offsets[start_row + rows_read - 1] - column_offsets[start_row - 1]; + const auto * data_low_cardinality = typeid_cast(&column_data); + if (data_low_cardinality && LowCardinalityDocumentsAdder::isApplicable(granule_builder, *data_low_cardinality, num_elements)) + { + addLowCardinalityArrayDocuments(granule_builder, *data_low_cardinality, column_offsets, start_row, rows_read, context); + return; + } + } + for (size_t i = start_row; i < start_row + rows_read; ++i) { /// Dense position counter: dropped (empty/null) tokens leave no gap, so positions diff --git a/tests/performance/text_index_low_cardinality_build.xml b/tests/performance/text_index_low_cardinality_build.xml new file mode 100644 index 000000000000..0f1d21e428d0 --- /dev/null +++ b/tests/performance/text_index_low_cardinality_build.xml @@ -0,0 +1,26 @@ + + + + CREATE TABLE text_index_low_cardinality + ( + service LowCardinality(String), + tags Array(LowCardinality(String)) + ) + ENGINE = MergeTree ORDER BY tuple() + + + + INSERT INTO text_index_low_cardinality SELECT + concat('service-', toString(cityHash64(number) % 50)), + arrayMap(i -> concat('tag-', toString(cityHash64(number, i) % 1000)), range(10)) + FROM numbers(2000000) + SETTINGS max_insert_threads = 1, max_threads = 1, min_insert_block_size_rows = 3000000, min_insert_block_size_bytes = 0 + + ALTER TABLE text_index_low_cardinality ADD INDEX idx_service service TYPE text(tokenizer = 'splitByNonAlpha') + ALTER TABLE text_index_low_cardinality ADD INDEX idx_tags tags TYPE text(tokenizer = 'splitByNonAlpha') + + ALTER TABLE text_index_low_cardinality CLEAR INDEX idx_service, MATERIALIZE INDEX idx_service SETTINGS mutations_sync = 2 + ALTER TABLE text_index_low_cardinality CLEAR INDEX idx_tags, MATERIALIZE INDEX idx_tags SETTINGS mutations_sync = 2 + + DROP TABLE IF EXISTS text_index_low_cardinality + diff --git a/tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.reference b/tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.reference new file mode 100644 index 000000000000..bc4f232b8e02 --- /dev/null +++ b/tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.reference @@ -0,0 +1,33 @@ +50 values 55 1 +300 values 310 1 +a3 600 900300 +a3 600 900300 +b49 60 91440 +b49 60 91440 +w3 430 645000 +w3 430 645000 +v299 10 16490 +v299 10 16490 +v300 0 0 +v300 0 0 +v17 w3 10 13670 +v17 w3 10 13670 +w3 w3 0 0 +w3 w3 0 0 +Nullable 17 1 +Nullable(FixedString) 2 1 +m 624 312350 +m 624 312350 +Array 14 1 +e3 215 107920 +e3 215 107920 +IN 23 1 +NOT IN 2 1 +upper 25 1 +keyValuePairs 13 1 +mapKeys 5 1 +ngrams 32 1 +preprocessor 4 1 +threshold 18 1 +build 1 1 1 +segments 1 1 diff --git a/tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.sql b/tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.sql new file mode 100644 index 000000000000..8cfc5435850f --- /dev/null +++ b/tests/queries/0_stateless/05258_text_index_low_cardinality_dictionary.sql @@ -0,0 +1,253 @@ +-- Tags: no-fasttest + +-- A text index over a LowCardinality column must hold exactly the tokens and posting lists of the same index over +-- a plain column with the same values, whichever way the index is built. Each case compares the two indexes token by +-- token, and checks the results of searches through the index against a full scan. + +SET use_skip_indexes = 1; +SET query_plan_direct_read_from_text_index = 1; +SET use_query_condition_cache = 0; + +CREATE TABLE t_main +( + id UInt64, + lc50 LowCardinality(String), s50 String, + lc300 LowCardinality(String), s300 String, + INDEX i_lc50 lc50 TYPE text(tokenizer = splitByNonAlpha), + INDEX i_s50 s50 TYPE text(tokenizer = splitByNonAlpha), + INDEX i_lc300 lc300 TYPE text(tokenizer = splitByNonAlpha, support_phrase_search = 1), + INDEX i_s300 s300 TYPE text(tokenizer = splitByNonAlpha, support_phrase_search = 1) +) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 8192, index_granularity_bytes = 104857600, allow_experimental_text_index_phrase_search = 1; + +-- Values share tokens and repeat a token within a value. More than 255 values need 16-bit dictionary positions. +INSERT INTO t_main SELECT number, + concat('a', toString(number % 50 % 5), ' b', toString(number % 50)) AS v50, v50, + concat('w', toString(number % 300 % 7), ' v', toString(number % 300), ' w', toString(number % 300 % 7), ' x', toString(number % 300 % 3)) AS v300, v300 +FROM numbers(3000); + +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_main, i_lc50)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_main, i_s50)) AS s +SELECT '50 values', lc.1, lc = s; +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_main, i_lc300)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_main, i_s300)) AS s +SELECT '300 values', lc.1, lc = s; + +SELECT 'a3', count(), sum(id) FROM t_main WHERE hasToken(lc50, 'a3'); +SELECT 'a3', count(), sum(id) FROM t_main WHERE hasToken(s50, 'a3') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; +SELECT 'b49', count(), sum(id) FROM t_main WHERE hasToken(lc50, 'b49'); +SELECT 'b49', count(), sum(id) FROM t_main WHERE hasToken(s50, 'b49') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; +SELECT 'w3', count(), sum(id) FROM t_main WHERE hasToken(lc300, 'w3'); +SELECT 'w3', count(), sum(id) FROM t_main WHERE hasToken(s300, 'w3') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; +SELECT 'v299', count(), sum(id) FROM t_main WHERE hasToken(lc300, 'v299'); +SELECT 'v299', count(), sum(id) FROM t_main WHERE hasToken(s300, 'v299') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; +SELECT 'v300', count(), sum(id) FROM t_main WHERE hasToken(lc300, 'v300'); +SELECT 'v300', count(), sum(id) FROM t_main WHERE hasToken(s300, 'v300') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; +SELECT 'v17 w3', count(), sum(id) FROM t_main WHERE hasPhrase(lc300, 'v17 w3'); +SELECT 'v17 w3', count(), sum(id) FROM t_main WHERE hasPhrase(s300, 'v17 w3') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; +SELECT 'w3 w3', count(), sum(id) FROM t_main WHERE hasPhrase(lc300, 'w3 w3'); +SELECT 'w3 w3', count(), sum(id) FROM t_main WHERE hasPhrase(s300, 'w3 w3') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; + +-- NULL, an empty value and a value without tokens; FixedString values with the `array` tokenizer. +CREATE TABLE t_null +( + id UInt64, + lcn LowCardinality(Nullable(String)), sn Nullable(String), + lcf LowCardinality(Nullable(FixedString(3))), sf Nullable(FixedString(3)), + INDEX i_lcn lcn TYPE text(tokenizer = splitByNonAlpha), + INDEX i_sn sn TYPE text(tokenizer = splitByNonAlpha), + INDEX i_lcf lcf TYPE text(tokenizer = array), + INDEX i_sf sf TYPE text(tokenizer = array) +) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 8192, index_granularity_bytes = 104857600; + +INSERT INTO t_null SELECT number, + multiIf(number % 5 = 0, NULL, number % 7 = 0, '', number % 11 = 0, '...', concat('n', toString(number % 20), ' m')) AS vn, vn, + if(number % 3 = 0, NULL, ['abc', 'de'][number % 2 + 1]) AS vf, vf +FROM numbers(1000); + +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_null, i_lcn)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_null, i_sn)) AS s +SELECT 'Nullable', lc.1, lc = s; +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_null, i_lcf)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_null, i_sf)) AS s +SELECT 'Nullable(FixedString)', lc.1, lc = s; + +SELECT 'm', count(), sum(id) FROM t_null WHERE hasToken(lcn, 'm'); +SELECT 'm', count(), sum(id) FROM t_null WHERE hasToken(sn, 'm') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; + +-- Arrays with NULL and empty elements, and tokens repeated in a row. +CREATE TABLE t_arr +( + id UInt64, + a Array(LowCardinality(Nullable(String))), sa Array(Nullable(String)), + INDEX i_a a TYPE text(tokenizer = splitByNonAlpha, support_phrase_search = 1), + INDEX i_sa sa TYPE text(tokenizer = splitByNonAlpha, support_phrase_search = 1) +) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 8192, index_granularity_bytes = 104857600, allow_experimental_text_index_phrase_search = 1; + +INSERT INTO t_arr SELECT number, + arrayMap(k -> multiIf(k = 0, NULL, k = 1, '', concat('e', toString((number + k) % 13), ' f e', toString(k))), range(number % 5)) AS v, v +FROM numbers(1000); + +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_arr, i_a)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_arr, i_sa)) AS s +SELECT 'Array', lc.1, lc = s; + +SELECT 'e3', count(), sum(id) FROM t_arr WHERE hasAnyTokens(a, 'e3'); +SELECT 'e3', count(), sum(id) FROM t_arr WHERE hasAnyTokens(sa, 'e3') SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0; + +-- Postprocessors: IN and NOT IN filters, and a postprocessor with phrase search. +CREATE TABLE t_postprocessor +( + id UInt64, + lc1 LowCardinality(String), s1 String, + lc2 LowCardinality(String), s2 String, + lc3 LowCardinality(String), s3 String, + INDEX i_lc1 lc1 TYPE text(tokenizer = splitByNonAlpha, postprocessor = if(lc1 IN ('w1', 'w2'), '', lc1)), + INDEX i_s1 s1 TYPE text(tokenizer = splitByNonAlpha, postprocessor = if(s1 IN ('w1', 'w2'), '', s1)), + INDEX i_lc2 lc2 TYPE text(tokenizer = splitByNonAlpha, postprocessor = if(lc2 NOT IN ('w1', 'w2'), '', lc2)), + INDEX i_s2 s2 TYPE text(tokenizer = splitByNonAlpha, postprocessor = if(s2 NOT IN ('w1', 'w2'), '', s2)), + INDEX i_lc3 lc3 TYPE text(tokenizer = splitByNonAlpha, postprocessor = upper(lc3), support_phrase_search = 1), + INDEX i_s3 s3 TYPE text(tokenizer = splitByNonAlpha, postprocessor = upper(s3), support_phrase_search = 1) +) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 8192, index_granularity_bytes = 104857600, allow_experimental_text_index_phrase_search = 1; + +INSERT INTO t_postprocessor SELECT number, v, v, v, v, v, v +FROM (SELECT number, concat('w', toString(number % 5), ' z', toString(number % 20)) AS v FROM numbers(1000)); + +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_postprocessor, i_lc1)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_postprocessor, i_s1)) AS s +SELECT 'IN', lc.1, lc = s; +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_postprocessor, i_lc2)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_postprocessor, i_s2)) AS s +SELECT 'NOT IN', lc.1, lc = s; +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_postprocessor, i_lc3)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_postprocessor, i_s3)) AS s +SELECT 'upper', lc.1, lc = s; + +-- Map indexes. +CREATE TABLE t_map +( + id UInt64, + m Map(LowCardinality(String), String), ms Map(String, String), + INDEX i_kv_lc m TYPE text(tokenizer = keyValuePairs), + INDEX i_kv_s ms TYPE text(tokenizer = keyValuePairs), + INDEX i_keys_lc mapKeys(m) TYPE text(tokenizer = array), + INDEX i_keys_s mapKeys(ms) TYPE text(tokenizer = array) +) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 8192, index_granularity_bytes = 104857600; + +INSERT INTO t_map SELECT number, map(concat('k', toString(number % 4)), toString(number % 3), 'key', 'value') AS v, v FROM numbers(1000); + +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_map, i_kv_lc)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_map, i_kv_s)) AS s +SELECT 'keyValuePairs', lc.1, lc = s; +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_map, i_keys_lc)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_map, i_keys_s)) AS s +SELECT 'mapKeys', lc.1, lc = s; + +-- The ngrams tokenizer, and a preprocessor that maps several values to one. +CREATE TABLE t_misc +( + id UInt64, + lc1 LowCardinality(String), s1 String, + lc2 LowCardinality(String), s2 String, + INDEX i_lc1 lc1 TYPE text(tokenizer = ngrams(3)), + INDEX i_s1 s1 TYPE text(tokenizer = ngrams(3)), + INDEX i_lc2 lc2 TYPE text(tokenizer = splitByNonAlpha, preprocessor = lower(lc2)), + INDEX i_s2 s2 TYPE text(tokenizer = splitByNonAlpha, preprocessor = lower(s2)) +) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 8192, index_granularity_bytes = 104857600; + +INSERT INTO t_misc SELECT number, v1, v1, v2, v2 +FROM (SELECT number, concat('abcd', toString(number % 30)) AS v1, ['Foo Bar', 'foo bar', 'FOO BAZ', 'qux'][number % 4 + 1] AS v2 FROM numbers(1000)); + +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_misc, i_lc1)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_misc, i_s1)) AS s +SELECT 'ngrams', lc.1, lc = s; +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_misc, i_lc2)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_misc, i_s2)) AS s +SELECT 'preprocessor', lc.1, lc = s; + +-- 9 values and the empty default value: 80 rows reuse the tokens of a value, 79 rows tokenize every row. +CREATE TABLE t_threshold +( + id UInt64, + lc LowCardinality(String), s String, + INDEX i_lc lc TYPE text(tokenizer = splitByNonAlpha), + INDEX i_s s TYPE text(tokenizer = splitByNonAlpha) +) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 8192, index_granularity_bytes = 104857600; + +SYSTEM STOP MERGES t_threshold; +INSERT INTO t_threshold SELECT number, concat('c', toString(number % 9)) AS v, v FROM numbers(80); +INSERT INTO t_threshold SELECT 1000 + number, concat('c', toString(number % 9)) AS v, v FROM numbers(79); + +WITH (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_threshold, i_lc)) AS lc, + (SELECT (count(), sum(cityHash64(*))) FROM mergeTreeTextIndex(currentDatabase(), t_threshold, i_s)) AS s +SELECT 'threshold', lc.1, lc = s; + +-- The index built by INSERT, by a merge that flushes several segments, and by MATERIALIZE INDEX. +CREATE TABLE t_insert (id UInt64, lc LowCardinality(String), INDEX i lc TYPE text(tokenizer = splitByNonAlpha)) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 1000, index_granularity_bytes = 104857600; +CREATE TABLE t_merge (id UInt64, lc LowCardinality(String)) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 1000, index_granularity_bytes = 104857600, text_index_max_processed_tokens_before_flush = 1000; +CREATE TABLE t_merge_s (id UInt64, s String) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 1000, index_granularity_bytes = 104857600, text_index_max_processed_tokens_before_flush = 1000; +CREATE TABLE t_materialize (id UInt64, lc LowCardinality(String)) +ENGINE = MergeTree ORDER BY id +SETTINGS index_granularity = 1000, index_granularity_bytes = 104857600; + +SYSTEM STOP MERGES t_merge; +SYSTEM STOP MERGES t_merge_s; +INSERT INTO t_insert SELECT number, concat('a', toString(number % 50 % 5), ' b', toString(number % 50)) FROM numbers(3000); +INSERT INTO t_merge SELECT number, concat('a', toString(number % 50 % 5), ' b', toString(number % 50)) FROM numbers(1500); +INSERT INTO t_merge SELECT number, concat('a', toString(number % 50 % 5), ' b', toString(number % 50)) FROM numbers(1500, 1500); +INSERT INTO t_merge_s SELECT number, concat('a', toString(number % 50 % 5), ' b', toString(number % 50)) FROM numbers(1500); +INSERT INTO t_merge_s SELECT number, concat('a', toString(number % 50 % 5), ' b', toString(number % 50)) FROM numbers(1500, 1500); +INSERT INTO t_materialize SELECT number, concat('a', toString(number % 50 % 5), ' b', toString(number % 50)) FROM numbers(3000); + +ALTER TABLE t_merge ADD INDEX i lc TYPE text(tokenizer = splitByNonAlpha); +ALTER TABLE t_merge_s ADD INDEX i s TYPE text(tokenizer = splitByNonAlpha); +SYSTEM START MERGES t_merge; +SYSTEM START MERGES t_merge_s; +OPTIMIZE TABLE t_merge FINAL; +OPTIMIZE TABLE t_merge_s FINAL; +ALTER TABLE t_materialize ADD INDEX i lc TYPE text(tokenizer = splitByNonAlpha); +ALTER TABLE t_materialize MATERIALIZE INDEX i SETTINGS mutations_sync = 2; + +WITH (SELECT (count(), sum(cityHash64(* EXCEPT part_name))) FROM mergeTreeTextIndex(currentDatabase(), t_merge_s, i)) AS s +SELECT 'build', + (SELECT (count(), sum(cityHash64(* EXCEPT part_name))) FROM mergeTreeTextIndex(currentDatabase(), t_insert, i)) = s, + (SELECT (count(), sum(cityHash64(* EXCEPT part_name))) FROM mergeTreeTextIndex(currentDatabase(), t_merge, i)) = s, + (SELECT (count(), sum(cityHash64(* EXCEPT part_name))) FROM mergeTreeTextIndex(currentDatabase(), t_materialize, i)) = s; + +SYSTEM FLUSH LOGS part_log; +WITH (SELECT sum(ProfileEvents['TextIndexTemporarySegmentsWritten']) FROM system.part_log + WHERE database = currentDatabase() AND table = 't_merge' AND event_type = 'MergeParts') AS lc, + (SELECT sum(ProfileEvents['TextIndexTemporarySegmentsWritten']) FROM system.part_log + WHERE database = currentDatabase() AND table = 't_merge_s' AND event_type = 'MergeParts') AS s +SELECT 'segments', lc = s, lc > 1; + +DROP TABLE t_main; +DROP TABLE t_null; +DROP TABLE t_arr; +DROP TABLE t_postprocessor; +DROP TABLE t_map; +DROP TABLE t_misc; +DROP TABLE t_threshold; +DROP TABLE t_insert; +DROP TABLE t_merge; +DROP TABLE t_merge_s; +DROP TABLE t_materialize; From 4a9ff4308c79735bd45f29afb4f14883bc828e04 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 26 Sep 2026 05:47:18 +0000 Subject: [PATCH 118/272] Put the disks of test `05234` under `CLICKHOUSE_DISKS_FILES` A relative `path` of a custom `local` object storage disk is refused by `checkCustomLocalDiskPath`: it must be inside `custom_local_disks_base_directory`. https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=119304&sha=bc26da2cc5f4937b8dadd13486950f653fa8a8ca&name_0=PR&name_1=Fast%20test https://github.com/ClickHouse/ClickHouse/pull/119304 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0_stateless/05234_read_only_disk_hard_links_capability.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh b/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh index baf74f836a2a..7982c5a96c83 100755 --- a/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh +++ b/tests/queries/0_stateless/05234_read_only_disk_hard_links_capability.sh @@ -15,8 +15,8 @@ CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../shell_config.sh . "$CUR_DIR"/../shell_config.sh -disk_path="disks/05234/${CLICKHOUSE_DATABASE}/" -disk_path_with_hard_links="disks/05234_hard_links/${CLICKHOUSE_DATABASE}/" +disk_path="${CLICKHOUSE_DISKS_FILES}/05234/${CLICKHOUSE_DATABASE}/" +disk_path_with_hard_links="${CLICKHOUSE_DISKS_FILES}/05234_hard_links/${CLICKHOUSE_DATABASE}/" ${CLICKHOUSE_CLIENT} -m --query " DROP TABLE IF EXISTS writer SYNC; From 770425c6e0c802344e0e5c1dd64c8718765104f0 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 26 Sep 2026 05:47:19 +0000 Subject: [PATCH 119/272] Address review: document `enable_hard_links` as startup-only `SYSTEM RELOAD CONFIG` does not apply it: `DiskObjectStorage::applyNewSettings` passes the config prefix of the outer disk, which for a disk wrapped with a cache is the prefix of the cache layer, so reading the setting on reload would take it from the wrong place. Changing it requires a restart, as a downgrade does anyway. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/configuration/server-config/storing-data.mdx | 2 ++ .../MetadataStorageFromPlainRewritableObjectStorage.h | 3 +++ 2 files changed, 5 insertions(+) diff --git a/docs/concepts/features/configuration/server-config/storing-data.mdx b/docs/concepts/features/configuration/server-config/storing-data.mdx index d1c8050e102a..4057c1c1015a 100644 --- a/docs/concepts/features/configuration/server-config/storing-data.mdx +++ b/docs/concepts/features/configuration/server-config/storing-data.mdx @@ -478,6 +478,8 @@ enabling it: older servers until the first operation that creates a hard link - a mutation, an `ALTER`, a `FREEZE` or a backup. Turning the setting off again does not convert the existing directories back. +- The setting is read only when the disk is created: a change takes effect after + a restart of the server, `SYSTEM RELOAD CONFIG` does not apply it. - An object whose last link is removed is deleted in two steps: the metadata of the directory is rewritten first, and the object is deleted after that. If the server is killed in between, the object is left in the bucket and is not diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h index 0a3691d46337..f210e0e409a9 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/PlainRewritable/MetadataStorageFromPlainRewritableObjectStorage.h @@ -108,6 +108,9 @@ private: const std::string storage_path_full; /// Real hard links require the explicit form of `prefix.path`, which older servers cannot read, /// so they are enabled by the `enable_hard_links` setting of the disk. See the comment above. + /// The setting is startup-only (`applyNewSettings` is not overridden): `DiskObjectStorage::applyNewSettings` passes the + /// config prefix of the outer disk, which is not the prefix of this one when the disk is wrapped with a cache, so a reload + /// would read the setting from the wrong place. Changing it requires a restart, as a downgrade does anyway. const bool hard_links_enabled; std::mutex metadata_mutex; From 3be8b324ce2020f6c7076e222037a3ebaab36b31 Mon Sep 17 00:00:00 2001 From: Groene AI <270696204+groeneai@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:46:48 +0000 Subject: [PATCH 120/272] Do not cache tokens for stateful tokenizers, keep one generation of cached builders The `japanese` tokenizer keeps the buffer it parsed last and re-parses only a new `(data, length)`, so tokenizing the same dictionary value right after itself can return no tokens. The INSERT writer calls `update` once per data granule on the same block, with one tokenizer for the whole index granule, so the first value of a granule could be cached with no tokens and its later rows in that granule were missing from the index. The cache is now used only with a tokenizer that keeps no state between calls, so `japanese` and `sparseGrams` keep the per-row path. Re-caching after the token map grows appended a new token range and never released the old one. Now the cached builders are cleared when the buffer size changes. `add` compares a value's recorded buffer size with the current one before it reads the value's range, and the buffer size only grows, so no value that points into the cleared entries can pass that check; such values are cached again at their next occurrence. Co-Authored-By: Claude Opus 5.5 --- src/Storages/MergeTree/MergeTreeIndexText.cpp | 12 +++++++++- .../test_japanese_tokenizer/test.py | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Storages/MergeTree/MergeTreeIndexText.cpp b/src/Storages/MergeTree/MergeTreeIndexText.cpp index 4cf2e3bac3d7..5eb2bcc7f452 100644 --- a/src/Storages/MergeTree/MergeTreeIndexText.cpp +++ b/src/Storages/MergeTree/MergeTreeIndexText.cpp @@ -1866,7 +1866,9 @@ public: static bool isApplicable(const MergeTreeIndexTextGranuleBuilder & builder, const ColumnLowCardinality & column, size_t num_documents) { static constexpr size_t min_documents_per_value = 8; - return !builder.postprocessor_drop_filter && column.getDictionary().size() * min_documents_per_value <= num_documents; + /// A stateful tokenizer can depend on its previous call, so the tokens of a value are not cached for it. + return !builder.postprocessor_drop_filter && !builder.tokenizer->isStateful() + && column.getDictionary().size() * min_documents_per_value <= num_documents; } ALWAYS_INLINE void add(size_t index) @@ -1908,6 +1910,12 @@ private: void addAndCache(size_t index, Value & value) { const size_t buffer_size = builder.tokens_map.getBufferSizeInCells(); + /// The builders taken at a smaller buffer size are stale. + if (buffer_size != tokens_buffer_size) + { + tokens.clear(); + tokens_buffer_size = buffer_size; + } const size_t begin = tokens.size(); if (!dictionary.isNullAt(index)) @@ -1946,6 +1954,8 @@ private: const PostingListBuildContext & context; std::vector values; std::vector tokens; + /// Buffer size of the map when the builders in tokens were taken. + size_t tokens_buffer_size = 0; }; NO_INLINE void addLowCardinalityDocuments( diff --git a/tests/integration/test_japanese_tokenizer/test.py b/tests/integration/test_japanese_tokenizer/test.py index c948e697dd50..daabd59aca0d 100644 --- a/tests/integration/test_japanese_tokenizer/test.py +++ b/tests/integration/test_japanese_tokenizer/test.py @@ -77,6 +77,29 @@ def test_text_index(started_cluster): node.query("DROP TABLE jp") +def test_text_index_low_cardinality(started_cluster): + skip_if_no_mecab(node) + node.query("DROP TABLE IF EXISTS jp_lc") + node.query( + """ + CREATE TABLE jp_lc (id UInt32, s LowCardinality(String), + INDEX idx s TYPE text(tokenizer = 'japanese')) + ENGINE = MergeTree ORDER BY id + """ + ) + # Three values in turn over several granules: no two neighbouring rows hold the same value. + node.query( + "INSERT INTO jp_lc SELECT number, ['日本語�形態素解�エンジン', '�れ�テスト�文章��', '日本語�テスト'][number % 3 + 1] FROM numbers(30000)" + ) + + for token in ["エンジン", "文章", "テスト"]: + query = f"SELECT count() FROM jp_lc WHERE hasAnyTokens(s, '{token}', 'japanese')" + full_scan = query + " SETTINGS use_skip_indexes = 0, query_plan_direct_read_from_text_index = 0" + assert node.query(query) == node.query(full_scan), token + + node.query("DROP TABLE jp_lc") + + def test_wrong_sha_fails_closed(started_cluster): skip_if_no_mecab(node_bad_sha) error = node_bad_sha.query_and_get_error("SELECT tokens('日本語', 'japanese')") From 43c650b13e11c56d6b7b3cf274f9a10338eb503c Mon Sep 17 00:00:00 2001 From: Groene AI <270696204+groeneai@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:43:37 +0000 Subject: [PATCH 121/272] Trim comments Shorten the class comment of LowCardinalityDocumentsAdder to one line. Co-Authored-By: Claude Opus 5.5 --- src/Storages/MergeTree/MergeTreeIndexText.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Storages/MergeTree/MergeTreeIndexText.cpp b/src/Storages/MergeTree/MergeTreeIndexText.cpp index 5eb2bcc7f452..0168263ecae9 100644 --- a/src/Storages/MergeTree/MergeTreeIndexText.cpp +++ b/src/Storages/MergeTree/MergeTreeIndexText.cpp @@ -1849,8 +1849,7 @@ void callWithLowCardinalityIndexes(const ColumnLowCardinality & column, Func && } } -/// Adds values of a LowCardinality column as documents. A dictionary value is tokenized at its first -/// occurrence only; later occurrences add the row to the cached posting list builders of its tokens. +/// Caches the posting list builders of the tokens of each dictionary value for its later occurrences. class LowCardinalityDocumentsAdder { public: From e5429278e9df2de6915cb3a84e4080734eacd09a Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sat, 26 Sep 2026 14:31:59 +0000 Subject: [PATCH 122/272] Relabel aggregate states read by `NativeReader` with the header's state version `IDataType::equals` ignores the state version of `AggregateFunction` types, so a version `0` `uniq` state read into a header that declares `AggregateFunction(1, uniq, UInt64)` kept its version `0` column. The states in memory do not depend on the version, but the column uses its version on an arena round trip, while the query expects the header's version, so e.g. `groupArray` over such a column failed with `INCORRECT_DATA` ("skip degree is 100"). When the types are equal but their nested state versions differ, relabel the column with the header type (sharing the states). Making `equals` itself version-aware is not an option: it would make a version `0` table column and a fresh `uniqState` result incompatible, e.g. in `UNION ALL`. Addresses the review thread https://github.com/ClickHouse/ClickHouse/pull/115393#discussion_r4086749613 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Formats/NativeReader.cpp | 32 +++++++++++++++++++ ...te_version_native_reader_relabel.reference | 2 ++ ...niq_state_version_native_reader_relabel.sh | 18 +++++++++++ 3 files changed, 52 insertions(+) create mode 100644 tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.reference create mode 100755 tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.sh diff --git a/src/Formats/NativeReader.cpp b/src/Formats/NativeReader.cpp index 53f7f911f0ba..424b3b7896a1 100644 --- a/src/Formats/NativeReader.cpp +++ b/src/Formats/NativeReader.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -35,6 +36,25 @@ namespace ErrorCodes extern const int TOO_LARGE_ARRAY_SIZE; } +namespace +{ + +/// The state versions of all `AggregateFunction` types in `type` (including nested ones), in a fixed traversal order. +std::vector getAggregateStateVersions(const IDataType & type) +{ + std::vector versions; + auto collect = [&](const IDataType & child) + { + if (const auto * aggregate_type = typeid_cast(&child)) + versions.push_back(aggregate_type->getVersion()); + }; + collect(type); + type.forEachChild(collect); + return versions; +} + +} + NativeReader::NativeReader( ReadBuffer & istr_, @@ -307,6 +327,18 @@ Block NativeReader::read() column.type = header_column.type; } + else if (getAggregateStateVersions(*header_column.type) != getAggregateStateVersions(*column.type)) + { + /// `equals` ignores the state version of `AggregateFunction` types, e.g. a version `0` state + /// from an older writer read into a `AggregateFunction(1, uniq, UInt64)` header. The states in memory + /// do not depend on the version, but a column keeps the version it was created with and uses it + /// when its states pass through an arena (e.g. in `groupArray`), where the query expects the version + /// of the header. So relabel the column with the header type; the states are shared, not copied. + auto relabelled_column = header_column.type->createColumn(); + relabelled_column->insertRangeFrom(*recursiveRemoveSparse(column.column), 0, column.column->size()); + column.column = std::move(relabelled_column); + column.type = header_column.type; + } } else { diff --git a/tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.reference b/tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.reference new file mode 100644 index 000000000000..13a4c27e4d5d --- /dev/null +++ b/tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.reference @@ -0,0 +1,2 @@ +AggregateFunction(1, uniq, UInt64) 100 [100] +Array(AggregateFunction(1, uniq, UInt64)) [[100]] diff --git a/tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.sh b/tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.sh new file mode 100755 index 000000000000..c4526ccf9fa1 --- /dev/null +++ b/tests/queries/0_stateless/05258_uniq_state_version_native_reader_relabel.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# A version `0` `uniq` state (as written by an older server) read into a header that declares +# `AggregateFunction(1, uniq, UInt64)`: the column must take the header's version, otherwise +# an arena round trip (`groupArray`) serializes the states as version `0` and reads them back as version `1`. + +$CLICKHOUSE_LOCAL -q "SELECT CAST(uniqState(number), 'AggregateFunction(uniq, UInt64)') AS s FROM numbers(100) FORMAT Native" \ + | $CLICKHOUSE_LOCAL --input-format Native --structure 's AggregateFunction(1, uniq, UInt64)' \ + -q "SELECT toTypeName(s), finalizeAggregation(s), arrayMap(x -> finalizeAggregation(x), groupArray(s)) FROM table GROUP BY ALL" + +# The same for a state nested in a container type. +$CLICKHOUSE_LOCAL -q "SELECT [CAST(uniqState(number), 'AggregateFunction(uniq, UInt64)')] AS s FROM numbers(100) FORMAT Native" \ + | $CLICKHOUSE_LOCAL --input-format Native --structure 's Array(AggregateFunction(1, uniq, UInt64))' \ + -q "SELECT toTypeName(s), arrayMap(x -> arrayMap(y -> finalizeAggregation(y), x), groupArray(s)) FROM table GROUP BY ALL" From c6f225df3aa6c7aa98d5d6378b2f0c75cb3a8006 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 27 Sep 2026 05:39:11 +0000 Subject: [PATCH 123/272] Address review: add a `ReplicatedMergeTree` restart test for a leftover inside the dropped range The regression test `05229_replace_partition_covers_outdated_parts` only covers plain `MergeTree`. The new test builds the same state for `ReplicatedMergeTree`: a mutated part `1_1_1_0_2` removed by `DROP PART` stays on disk (cleanup is stopped) while the mutation is stuck on the only active part `1_0_0_0`. The covering part written by `REPLACE PARTITION` must take the mutation version of the leftover (`1_0_3_1_2`), otherwise (`1_0_3_1`) the table fails to attach after DETACH + ATTACH with "Part ... intersects previous part ...". Co-Authored-By: Claude Opus 5.5 (1M context) --- ...covers_outdated_parts_replicated.reference | 2 + ...tition_covers_outdated_parts_replicated.sh | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.reference create mode 100755 tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.sh diff --git a/tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.reference b/tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.reference new file mode 100644 index 000000000000..c75fa130dbc9 --- /dev/null +++ b/tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.reference @@ -0,0 +1,2 @@ +1_1_1_0_2 +1 3 0 diff --git a/tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.sh b/tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.sh new file mode 100755 index 000000000000..608fff9cb3c3 --- /dev/null +++ b/tests/queries/0_stateless/05259_replace_partition_covers_outdated_parts_replicated.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Tags: zookeeper, no-replicated-database, no-shared-merge-tree +# no-replicated-database: the test relies on the block numbers and the mutation version of a single, freshly created table. +# no-shared-merge-tree: the test is about the covering part that `ReplicatedMergeTree` writes on `REPLACE PARTITION`. + +# The `ReplicatedMergeTree` counterpart of `05229_replace_partition_covers_outdated_parts`. +# `REPLACE PARTITION` writes an empty part that covers the replaced ones, so that a restart before they +# are unlinked does not resurrect them. Parts that were outdated earlier can still be on disk as well, +# and the covering part has to contain those too - otherwise the part loader finds a pair of parts that +# neither contain one another nor are disjoint, and the table fails to attach with +# "Part ... intersects previous part ...". +# +# Here the leftover is a mutated part `1_1_1_0_2` removed by `DROP PART`, while the mutation is stuck +# on the only remaining active part `1_0_0_0`. The covering part must take the mutation version of the +# leftover: `1_0_3_1` does not contain `1_1_1_0_2`, `1_0_3_1_2` does. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +$CLICKHOUSE_CLIENT -q " + DROP TABLE IF EXISTS t_replace_cover_rep SYNC; + DROP TABLE IF EXISTS t_replace_cover_rep_src; + + CREATE TABLE t_replace_cover_rep (p UInt64, k UInt64, v UInt64) + ENGINE = ReplicatedMergeTree('/clickhouse/tables/$CLICKHOUSE_TEST_ZOOKEEPER_PREFIX/t_replace_cover_rep', 'r1') + PARTITION BY p ORDER BY k SETTINGS old_parts_lifetime = 100000; + + CREATE TABLE t_replace_cover_rep_src (p UInt64, k UInt64, v UInt64) ENGINE = MergeTree PARTITION BY p ORDER BY k; + + -- Keep the outdated parts on disk until the simulated restart. + SYSTEM STOP CLEANUP t_replace_cover_rep; + + INSERT INTO t_replace_cover_rep SETTINGS async_insert = 0 VALUES (1, 1, 0); + INSERT INTO t_replace_cover_rep SETTINGS async_insert = 0 VALUES (1, 2, 0); + + -- The mutation succeeds on \`1_1_1_0\` and keeps failing on \`1_0_0_0\`. + ALTER TABLE t_replace_cover_rep UPDATE v = v + throwIf(k = 1) WHERE 1 SETTINGS mutations_sync = 0; +" + +for _ in {1..600} +do + [[ $($CLICKHOUSE_CLIENT -q "SELECT count() FROM system.parts WHERE database = currentDatabase() AND table = 't_replace_cover_rep' AND name = '1_1_1_0_2' AND active") == 1 ]] && break + sleep 0.1 +done + +$CLICKHOUSE_CLIENT -q " + -- A part in the middle of the partition is dropped without a covering part, so \`1_1_1_0_2\` stays on disk + -- as an outdated part that nothing active covers. + ALTER TABLE t_replace_cover_rep DROP PART '1_1_1_0_2'; + + INSERT INTO t_replace_cover_rep_src VALUES (1, 3, 0); + + ALTER TABLE t_replace_cover_rep REPLACE PARTITION 1 FROM t_replace_cover_rep_src; + + SELECT name FROM system.parts WHERE database = currentDatabase() AND table = 't_replace_cover_rep' AND name = '1_1_1_0_2'; + + -- DETACH + ATTACH rebuilds the set of parts from disk, just like a server restart. + DETACH TABLE t_replace_cover_rep; + ATTACH TABLE t_replace_cover_rep; + + SELECT * FROM t_replace_cover_rep ORDER BY k; + + DROP TABLE t_replace_cover_rep SYNC; + DROP TABLE t_replace_cover_rep_src; +" From fd719b912618e3253709fb615f8e9e775a0fe9e0 Mon Sep 17 00:00:00 2001 From: Alexey Milovidov Date: Sun, 27 Sep 2026 07:08:33 +0000 Subject: [PATCH 124/272] Address review: forget the passwordless marker after any authenticated request `rememberLoginPassword` was only called from `checkCredentials` and from a 2xx response in `postImpl`. On deployments where `checkURL` never reaches `checkCredentials` (the ping probes the origin, not the configured path), the database panel, `auxiliaryQuery` or the completions can be the first request to authenticate with a newly typed password, and a query that fails after authenticating (a syntax error, an unknown table) also proves the credentials. The new `rememberAuthenticationOutcome` is called from `auxiliaryQuery`, `loadCompletions` and `postImpl` (on the response the framing retries settled on). `401`/`403` never update the entry; another error updates it when the server itself produced it (`X-ClickHouse-Exception-Code`); without that proof a non-empty password still forgets the entry, but an empty one is not remembered. Adds the `test_play_passwordless_logins` harness test. Co-Authored-By: Claude Opus 5.5 (1M context) --- programs/server/play.html | 49 ++++-- .../test_play_passwordless_logins/__init__.py | 0 .../passwordless_harness.js | 155 ++++++++++++++++++ .../test_play_passwordless_logins/test.py | 82 +++++++++ 4 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 tests/integration/test_play_passwordless_logins/__init__.py create mode 100644 tests/integration/test_play_passwordless_logins/passwordless_harness.js create mode 100644 tests/integration/test_play_passwordless_logins/test.py diff --git a/programs/server/play.html b/programs/server/play.html index 6d6d7c7b1733..90f42b29e408 100644 --- a/programs/server/play.html +++ b/programs/server/play.html @@ -8326,6 +8326,9 @@ async function auxiliaryQuery(server_address, user, password, query, url_params if (on_error) on_error("Network error"); return false; } + /// Recorded from the values the request was sent with, whether or not it still belongs to the + /// active connection: the outcome is about these credentials, not the live fields. + rememberAuthenticationOutcome(response, server_address, user, password); if (!response.ok) { let error_text = await response.text(); if (!quiet && belongs_to_active_connection()) { @@ -9791,6 +9794,30 @@ function rememberLoginPassword(server_address, user, password) { try { window.localStorage.setItem(PASSWORDLESS_LOGINS_KEY, JSON.stringify([...logins])); } catch (e) { /* best-effort, see above */ } } +/// Record what the HTTP `response` to a request sent as `server_address`/`user` with `password` tells +/// about these credentials (see `rememberLoginPassword`). Called from every request that authenticates +/// with the connection fields, not only from `checkCredentials`: the ping in `checkURL` probes the +/// origin rather than the configured path, so on some deployments the credentials check never runs, +/// and a query, the database panel or the completions can be the first request to authenticate. +/// +/// ClickHouse authenticates before it executes the query, so the query's own error (a syntax error, an +/// unknown table) follows a successful authentication just like a success does. A rejected login is +/// answered with `401` or `403` (`403` also covers `ACCESS_DENIED`, which does follow a successful +/// authentication, but the status cannot tell the two apart, so it proves nothing either way). Any +/// other error proves the authentication only when the server itself produced it, which the +/// `X-ClickHouse-Exception-Code` header shows: a proxy in front of the server can answer on its own. +/// The header is not exposed to a cross-origin page, where only a successful response proves it. +/// +/// Without the proof a non-empty password still forgets the passwordless entry: at worst the page then +/// keeps an autofilled password it would have dropped, which is the pre-existing behaviour. An empty +/// password is remembered only with the proof, since the entry makes the page drop a saved password. +function rememberAuthenticationOutcome(response, server_address, user, password) { + if (response.status === 401 || response.status === 403) return; + if (response.ok || response.headers.get('X-ClickHouse-Exception-Code') !== null || password) { + rememberLoginPassword(server_address, user, password); + } +} + /// Whether the browser filled the field itself (its password manager), as opposed to the user typing /// or the page setting it. Reported by the standard `:autofill` pseudo-class, or its `-webkit-` form in /// older engines; a browser supporting neither (or an environment without `CSS.supports`, such as the @@ -11175,11 +11202,6 @@ async function postImpl(tab, posted_request_num, query, targetResultEl, abortCon const fetch_options = { method: "POST", body: query, signal: abortController.signal, headers: { 'Authorization': 'never' } }; response = await fetch(use_framing ? framing_url : url, fetch_options); - /// Authentication precedes the query, so a successful response means the snapshotted credentials - /// were accepted (see `rememberLoginPassword`). A failure is not recorded either way: it may be - /// the query's own error as well as a rejected login. - if (response.ok) rememberLoginPassword(connection.url, connection.user, connection.password); - /// Detect image results (e.g. `FORMAT PNG`) by the response `Content-Type` rather than by /// parsing the query, so any format that emits an image is rendered inline as a picture. let content_type = response.headers.get('Content-Type') ?? ''; @@ -11231,6 +11253,10 @@ async function postImpl(tab, posted_request_num, query, targetResultEl, abortCon } } + /// The response the retries above settled on tells whether the snapshotted credentials were + /// accepted, a failure of the query itself included (see `rememberAuthenticationOutcome`). + rememberAuthenticationOutcome(response, connection.url, connection.user, connection.password); + format = response.headers.get('X-ClickHouse-Format') ?? default_format; /// An image output format (`PNG`) is detected by the `X-ClickHouse-Format` header as well as @@ -18051,11 +18077,7 @@ function wasmAvailable() { } /// Build the request URL the same way `postImpl` does, but for the completion query. -function buildCompletionUrl() { - dropAutofilledStalePassword(); - const user = user_elem.value; - const password = password_elem.value; - const server_address = url_elem.value; +function buildCompletionUrl(server_address, user, password) { let url = server_address + (server_address.indexOf('?') >= 0 ? '&' : '?') + 'add_http_cors_header=1'; if (user) url += '&user=' + encodeURIComponent(user); if (password) url += '&password=' + encodeURIComponent(password); @@ -18091,12 +18113,17 @@ async function loadCompletions() { completionsLoadStarted = true; const generation = connectionGeneration; + /// The completion query is a request with the live credentials (see `dropAutofilledStalePassword`). + dropAutofilledStalePassword(); + const [server_address, user, password] = [url_elem.value, user_elem.value, password_elem.value]; + try { - const response = await fetch(buildCompletionUrl(), { + const response = await fetch(buildCompletionUrl(server_address, user, password), { method: "POST", body: COMPLETION_QUERY, headers: { 'Authorization': 'never' }, }); + rememberAuthenticationOutcome(response, server_address, user, password); /// The connection settings changed while loading — discard this stale result. if (generation !== connectionGeneration) { return; diff --git a/tests/integration/test_play_passwordless_logins/__init__.py b/tests/integration/test_play_passwordless_logins/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/integration/test_play_passwordless_logins/passwordless_harness.js b/tests/integration/test_play_passwordless_logins/passwordless_harness.js new file mode 100644 index 000000000000..08186f7e494e --- /dev/null +++ b/tests/integration/test_play_passwordless_logins/passwordless_harness.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/// Executable regression harness for the `/play` bookkeeping of passwordless logins. +/// +/// Runs the REAL helpers extracted from the served `play.html` - the block from +/// `PASSWORDLESS_LOGINS_KEY` up to the autofill detection, which needs no DOM - against a stubbed +/// `localStorage`, and asserts the contracts of `rememberAuthenticationOutcome`: +/// +/// - a successful response with an empty password remembers the login as passwordless, and a +/// successful response with a real password forgets it again; +/// - a response carrying the query's own error (a syntax error, `X-ClickHouse-Exception-Code` +/// set) follows a successful authentication, so it updates the entry the same way; +/// - a rejected login (`401` / `403`) never updates the entry; +/// - an error response that did not come from the server (no `X-ClickHouse-Exception-Code`, as +/// from a proxy) never remembers an empty password, but a real password still forgets it. +/// +/// Driven by `test.py` inside the `clickhouse/mysql-js-client` container (node:22-alpine), +/// against the `/play` page served by a real ClickHouse server. Can also be run standalone +/// against a checkout for development: node passwordless_harness.js programs/server/play.html +/// +/// Usage: node passwordless_harness.js +/// Exit code 0 = all scenarios pass; 1 = failure (details on stdout). + +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); + +function extractScript(html) { + const blocks = [...html.matchAll(/]*>([\s\S]*?)<\/script>/g)].map(m => m[1]); + if (!blocks.length) throw new Error('no