From c6b70b3155c96a5035bd121aea72c876b326b040 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Wed, 5 Aug 2026 18:12:07 +0300 Subject: [PATCH 01/39] Parquet v3: materialize constant column chunks from statistics When a Parquet column chunk provably holds a single value in every row - its min/max statistics have `min_value == max_value`, no nulls, and the value is exact - the reader no longer fetches or decodes that chunk's data pages. Instead `detectConstantColumn` records the value and `decodePrimitiveColumn`/`formOutputColumn` materialize it directly. This skips the offset index, column index, dictionary page and data page reads for such chunks (the row group already passed the key condition via its `min == max` hyperrectangle), which is a byte-level I/O win for wide constant columns, plus the decode/decompression CPU. Restricted to flat, top-level primitive columns with no element nulls. For `BYTE_ARRAY`/`FIXED_LEN_BYTE_ARRAY` the writer may truncate min/max, so `min == max` is trusted only when `is_min_value_exact` and `is_max_value_exact` are both set; fixed-width numeric types are never truncated. The value is taken from `PageDecoderInfo::decodeField`, which yields it in the final output (post-cast) domain - e.g. `DateTime` written as `TIMESTAMP_MILLIS` decodes to seconds, not the raw millisecond `decoded_type`. So the constant is materialized directly in the output type, bypassing the `decoded_type` column and `castColumn`. Gated by the new setting `input_format_parquet_use_constant_column_optimization` (default on). A new `ParquetConstantColumnChunks` ProfileEvent counts materialized chunks. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Common/ProfileEvents.cpp | 1 + src/Core/FormatFactorySettings.h | 3 + src/Core/SettingsChangesHistory.cpp | 90 ++++++++++++++ src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 1 + .../Formats/Impl/Parquet/Reader.cpp | 115 ++++++++++++++++-- src/Processors/Formats/Impl/Parquet/Reader.h | 20 +++ ...uet_constant_column_optimization.reference | 12 ++ ...11_parquet_constant_column_optimization.sh | 69 +++++++++++ 9 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference create mode 100755 tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 7f35ef869e92..6d38d90e0549 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1442,6 +1442,7 @@ The server successfully detected this situation and will download merged part fr \ M(ParquetReadRowGroups, "The total number of row groups read from parquet data", ValueType::Number) \ M(ParquetPrunedRowGroups, "The total number of row groups pruned from parquet data", ValueType::Number) \ + M(ParquetConstantColumnChunks, "The total number of parquet column chunks materialized from a single value in their min/max statistics, without reading their data pages", ValueType::Number) \ M(ParquetDecodingTasks, "Tasks issued by parquet reader", ValueType::Number) \ M(ParquetDecodingTaskBatches, "Task groups sent to a thread pool by parquet reader", ValueType::Number) \ M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 67396c955eac..efea4bddcf4d 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -206,6 +206,9 @@ Skip pages using min/max values from column index. )", 0) \ DECLARE(Bool, input_format_parquet_use_offset_index, true, R"( Minor tweak to how pages are read from parquet file when no page filtering is used. +)", 0) \ + DECLARE(Bool, input_format_parquet_use_constant_column_optimization, true, R"( +When a Parquet column chunk provably holds a single value in every row (according to its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages. )", 0) \ DECLARE(Bool, input_format_parquet_verify_checksums, true, R"( Verify page checksums when reading parquet files. diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index c2b62c209863..07bdc0a1bd1f 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -39,6 +39,96 @@ 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.8", + { + {"max_insert_threads", 1, 0, "Changed the default from 1 (no parallel execution) to auto (0), which resolves to the number of CPU cores available to the server, reduced under memory pressure via `max_insert_threads_min_free_memory_per_thread`. This parallelizes `INSERT SELECT` by default. Set to 1 to restore the previous single-threaded behavior."}, + {"unique_key_probe_implementation", "auto", "auto", "New setting: selects the UNIQUE KEY probe implementation (currently only the simple baseline exists)"}, + {"s3_base", "", "", "New setting to specify the base URL for resolving relative URLs in the s3 table function and the S3 table engine."}, + {"use_query_condition_cache_for_top_k", false, false, "New setting to gate the query condition cache for `ORDER BY ... LIMIT n` (TopK) reads; disabled by default."}, + {"use_projection_index_in_read_pools", false, false, "New setting to drop mark ranges fully filtered out by a projection index before read tasks are created in MergeTree read pools."}, + {"allow_distinct_partitions_independently", false, true, "New setting to enable independent per-partition evaluation of `DISTINCT` when the partition expression is a deterministic function of the `DISTINCT` columns."}, + {"force_distinct_partitions_independently", false, false, "New setting to force independent per-partition evaluation of `DISTINCT` even when the cost heuristic would skip it."}, + {"max_number_of_partitions_for_independent_distinct", 128, 128, "New setting: maximal number of partitions to apply independent per-partition `DISTINCT`."}, + {"allow_lossy_numeric_supertype", false, false, "New setting that lets if/multiIf/coalesce/ifNull/array/map resolve all-numeric branches with no lossless common type (e.g. Decimal + Float64) to a numeric supertype (Float64, with possible precision loss), so the result can be aggregated. Independent of use_variant_as_common_type: with it off such branches previously raised NO_COMMON_TYPE, with it on they became a Variant; either way they now resolve to Float64."}, + {"throw_on_hive_partitioning_resolution_failure", false, true, "New setting to fail the query when Hive-style partitioning detection for an object storage table cannot list the storage, instead of running without the Hive partition columns."}, + {"allow_experimental_json_ast_dialect", false, false, "New setting to enable the `clickhouse_json` value of the `dialect` setting, which interprets queries as JSON ASTs (the output of `parseQueryToJSON`) instead of SQL text."}, + {"analyzer_compatibility_apply_final_to_all_joined_tables", false, false, "New setting on master (default false = the fixed behavior). The behavior flip itself is recorded under 26.6, and the introduction for backports to older release branches (with default true) under 26.4."}, + {"enable_parallel_single_level_merge", false, true, "New setting to parallelize the final merge of the single-level aggregation hash tables by splitting the key space into disjoint hash partitions that the threads merge independently."}, + {"ai_function_text_default_credentials", "", "", "New setting"}, + {"ai_function_embedding_default_credentials", "", "", "New setting"}, + {"ai_function_allow_insecure_endpoint", true, false, "AI functions now reject insecure (http) endpoints to remote hosts by default."}, + {"ai_function_max_api_calls_per_query", 0, 1000, "Bound outbound AI function HTTP calls per query by default (previously 0 - unlimited)."}, + {"join_runtime_filter_min_probe_rows", 0, 1000, "New setting to control minimum probe side size for installing JOIN runtime filters. It wasn't limited before, so previous value is 0 meaning always install."}, + {"optimize_trivial_count_with_sparsity_filter", false, true, "Promote to BETA and enable by default: serve `SELECT count() FROM t WHERE ` from the persisted per-column `num_defaults` / `num_rows` counters when `` partitions rows into defaults vs non-defaults. Requires the MergeTree setting `compute_exact_num_defaults_for_sparse_columns` (also enabled by default now)."}, + {"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)."}, + {"input_format_arrow_use_native_reader", true, true, "Obsolete setting, the native ClickHouse reader is now always used for the `Arrow` and `ArrowStream` formats (the Apache Arrow library-based reader has been removed)."}, + {"output_format_arrow_use_native_writer", true, true, "Obsolete setting, the native ClickHouse writer is now always used for the `Arrow` and `ArrowStream` formats (the Apache Arrow library-based writer has been removed)."}, + {"distributed_cache_min_inflight_bytes_to_discard_connection_on_seek", 0, 4 * 1024 * 1024, "New setting to drop and reopen a distributed cache connection on a seek when too many in-flight bytes would otherwise be discarded. Defaults to 4 MiB; 0 restores the previous behavior (always reuse the connection via the read range id)."}, + {"input_format_parquet_spatial_filter_push_down", false, true, "New setting: skip GeoParquet row groups and pages based on spatial predicates and bounding box statistics"}, + {"use_text_index_negative_tokens_cache", false, true, "New setting to cache absent text index tokens and avoid repeated dictionary lookups."}, + {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, + }); + addSettingsChanges(settings_changes_history, "26.7", + { + {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, + {"dictionary_lazy_load", "auto", "auto", "New setting overriding the server setting `dictionaries_lazy_load` for an individual dictionary."}, + {"discard_query_data", false, false, "New setting to skip sending query result rows to the client over the native TCP protocol."}, + {"optimize_trivial_count_with_sparsity_filter", false, false, "New (experimental) setting to serve `SELECT count() FROM t WHERE ` from per-column `num_defaults` / `num_rows` recorded in `serialization.json` when `` partitions rows into defaults vs non-defaults."}, + {"merge_tree_generic_exclusion_search_max_steps", 0, 0, "New setting to limit the number of steps of the generic exclusion search over the primary key index."}, + {"use_streaming_marks_compression", false, false, "New setting to compress marks into in-memory representation one block at a time (streaming) instead of materializing the full plain marks array, reducing peak memory during marks loading for compact parts with many substreams."}, + {"s3_validate_etag_on_read", false, true, "New setting to detect concurrent in-place overwrites of S3/GCS objects during a read by validating the GET response ETag against the listed one. previous_value=false so `compatibility` with versions before 26.7 restores the pre-existing behavior (no validation)."}, + {"dead_blobs_to_delay_insert", 0, 0, "New setting to override the `MergeTree` setting with the same name per query."}, + {"dead_blobs_to_throw_insert", 0, 0, "New setting to override the `MergeTree` setting with the same name per query."}, + {"input_format_csv_missing_nullable_as_empty_string", false, false, "New setting to read a missing value of `Nullable(String)` from CSV as an empty string instead of NULL."}, + {"use_legacy_to_time", true, false, "Use the new `toTime` function (converting values to the `Time` data type) by default instead of the legacy `toTime` (which is still available as `toTimeWithFixedDate`)."}, + {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, + {"parallel_replicas_plan_based", false, false, "New setting"}, + {"use_paimon_metadata_files_cache", false, false, "New setting to enable in-memory caching of parsed Paimon metadata files (manifest lists and manifests). For persistent Paimon table engines it must be enabled before metadata initialization; table functions evaluate it per query. Avoids repeated downloads and deserialization of metadata files from object storage on subsequent queries."}, + {"optimize_or_like_chain", false, true, "Enable by default: optimize OR chains of LIKE/ILIKE/match into multiSearchAny (pure-substring patterns) or multiMatchAny (other patterns, when Hyperscan/Vectorscan is permitted); when neither fast path applies the original OR chain is kept unchanged."}, + {"optimize_or_like_chain_min_patterns", 0, 10, "New setting controlling the minimum number of non-pure-substring LIKE/ILIKE/match branches (sharing the same LHS expression) required for optimize_or_like_chain to rewrite a chain into multiMatchAny. Shorter chains are kept as-is because the multiMatchAny (Hyperscan) rewrite only becomes faster than short-circuit OR evaluation from about nine branches."}, + {"optimize_or_like_chain_min_substrings", 0, 4, "New setting controlling the minimum number of pure-substring (%needle%) LIKE/ILIKE branches (sharing the same LHS expression) required for optimize_or_like_chain to rewrite a chain into multiSearchAny."}, + {"input_format_arrow_use_native_reader", false, true, "New setting to use the native ClickHouse reader for the Arrow and ArrowStream formats instead of the Apache Arrow library."}, + {"input_format_orc_use_fast_decoder", true, true, "Obsolete setting, the native ClickHouse ORC decoder is now always used (the Apache Arrow-based ORC reader has been removed)."}, + {"output_format_arrow_use_native_writer", false, true, "New setting to use the native ClickHouse writer for the Arrow and ArrowStream formats instead of the Apache Arrow library."}, + {"allow_minmax_index_for_json", true, false, "Forbid creating minmax skip index on JSON columns by default because the index serialization cannot handle heterogeneous Field values"}, + {"s3_allow_server_credentials_in_user_queries", true, false, "New setting to block S3 access from user SQL from resolving the server's own ambient credentials (environment/IMDS/IRSA/instance-profile/AWS-config-file/GCP-OAuth-metadata). Explicit role_arn-based STS assume-role is still allowed. The previous behavior (allowed) is restored with compatibility settings."}, + {"query_plan_merge_expression_into_join", false, true, "New setting. Allow to merge Expression step into JOIN step during join reordering optimization."}, + {"skip_unavailable_shards_mode", "unavailable_or_table_missing", "unavailable_or_table_missing", "New setting to control which exceptions from a remote shard are ignored when `skip_unavailable_shards` is enabled. The default matches the historical behavior: a shard whose table is missing is treated as unavailable."}, + {"use_text_index_tokens_cache", false, true, "Enabled the text index tokens cache globally."}, + {"use_text_index_header_cache", false, true, "Enabled the text index header cache globally."}, + {"optimize_aggregation_in_order_limit", false, true, "New setting to push the `LIMIT` into aggregation-in-order for early termination when the `ORDER BY` is a prefix of the `GROUP BY` sort description."}, + {"explain_query_plan_default", "legacy", "pretty", "From 26.7, `EXPLAIN PLAN` defaults to `actions=1, compact=1, pretty=1`. Set this to `legacy` to restore the pre-26.7 output."}, + {"format_geojson_validate_geometry", true, true, "New setting that controls whether the GeoJSON format enforces RFC 7946 geometry validity (minimum points per line and ring, ring closure, non-empty multi-geometries) when reading and writing"}, + {"use_partition_minmax_for_primary_key_pruning", false, true, "New setting to use the part's partition minmax to prune more granules during primary key analysis for `MergeTree` tables, when a primary key column is also an input column of the partition key."}, + {"allow_delta_lake_writes", false, false, "Added an alias for setting `allow_experimental_delta_lake_writes`, which was moved to Beta."}, + {"allow_experimental_delta_lake_writes", false, false, "Delta Lake writes were moved to Beta."}, + {"optimize_redundant_comparisons", false, true, "New setting to detect conflicting and redundant comparison conditions on the same expression within AND chains."}, + {"mysql_datatypes_support_level", "decimal,datetime64,date2Date32", "decimal,datetime64,date2Date32,geometry", "Map MySQL's concrete spatial types (LINESTRING, POLYGON, MULTILINESTRING, MULTIPOLYGON, MULTIPOINT) and the generic GEOMETRY type to the corresponding ClickHouse geometric types by default. The generic GEOMETRY column maps to the umbrella Geometry type; reading a value whose subtype has no ClickHouse counterpart (GEOMETRYCOLLECTION) throws at read time."}, + {"snappy_mode", "basic", "basic", "New setting to control the wire format used for snappy compression in generic file/URL I/O. The default `basic` preserves backward-compatible Hadoop snappy block format reads; HTTP `Content-Encoding: snappy` always uses the framing format independently of this setting."}, + {"compile_regular_expressions", false, true, "New setting to enable JIT compilation of simple regular expressions in functions like `match` and `extract`."}, + {"min_count_to_compile_regular_expression", 3, 3, "New setting controlling how many times a regular expression must be used before it is JIT-compiled."}, + {"allow_aggregate_partitions_independently", false, true, "Enable independent per-partition aggregation by default when the partition key suits the GROUP BY key. The existing runtime heuristics in `ReadFromMergeTree::requestOutputEachPartitionThroughSeparatePortForAggregation` already skip the optimization when the partition layout is unfavorable (too few partitions, too many partitions, or significantly skewed partition sizes), so enabling the setting is safe in the cases where it would otherwise be a no-op."}, + {"text_index_lazy_intersection_density_threshold", 0.2, 0.2, "Renamed from `text_index_density_threshold` (kept as an alias); selects the posting list intersection algorithm in lazy posting list apply mode."}, + {"allow_experimental_text_index_lazy_apply", false, true, "Lazy posting list apply mode for the text index is no longer experimental; the setting is now obsolete and has no effect (lazy mode is selected via `text_index_posting_list_apply_mode = 'lazy'`)."}, + {"allow_experimental_url_wildcard_from_index_pages", false, false, "New setting to enable expanding wildcards in the `url` table function by listing HTTP index pages."}, + {"url_wildcard_max_directories_to_read", 100000, 100000, "New setting to limit the number of directories read when expanding wildcards in the `url` table function."}, + {"allow_experimental_eval_table_function", false, false, "New setting to enable the experimental table function `eval`."}, + {"output_format_csv_header_serialize_tuple_into_separate_columns", false, true, "New setting. When output_format_csv_serialize_tuple_into_separate_columns is enabled, the CSVWithNames/CSVWithNamesAndTypes header now flattens Tuple columns into their leaf fields so the header width matches the data. Set to false to restore the previous single-name header."}, + {"enable_join_runtime_filters_index_analysis", false, false, "New setting to enable join filtering using dynamic index analysis"}, + {"vector_search_use_quantized_codes", false, false, "New setting to opt into the two-stage approximate vector-search optimization over a Quantize(...) column codec; queries stay exact by default."}, + {"reader_executor_use_long_connections", false, false, "New experimental ReaderExecutor setting (off by default): reuse a held source connection across sequential windows."}, + {"reader_executor_min_bytes_for_seek", 2097152, 2097152, "New experimental ReaderExecutor setting: forward-gap bound for bridging on a held source connection."}, + {"reader_executor_max_tail_for_drain", 1048576, 1048576, "New experimental ReaderExecutor setting: drain bound for completing a dropped long connection."}, + {"precise_float_parsing", false, true, "Use the precise (closest-representable) float parsing algorithm by default, now that it is faster than the previous fast algorithm. Set to false to restore the pre-26.7 fast-but-less-accurate parsing in conversion functions."}, + {"optimize_and_compare_chain_max_hash_work", 0, 5'000'000, "New setting that bounds the work of the `optimize_and_compare_chain` optimization (measured in query-tree nodes hashed) so it cannot dominate analysis of queries with very many or very large `AND`-chains of comparisons. The previous value `0` (unlimited) reproduces the pre-26.7 behavior where the optimization was uncapped, so `compatibility` set to an earlier version keeps deriving transitive predicates without a budget. Set to `0` to disable the budget."}, + {"iceberg_manifest_min_count_to_compact", 30, 30, "New setting to control manifest compaction for Iceberg tables."}, + {"show_remote_databases_in_system_tables", true, true, "New setting to control whether `MySQL` and `PostgreSQL` databases are shown in `system.tables`, `system.columns` and `system.completions`."}, + {"use_constant_folding_in_index_analysis", false, false, "New setting to fold partition-level constants into the filter predicate per part during MergeTree index analysis, improving pruning for filters whose branches depend on partition values."}, + {"join_runtime_filter_size_from_hash_table_stats", false, true, "Use hash table size statistics collected from previous executions to size the JOIN runtime filter. When disabled, fall back to the fixed `join_runtime_bloom_filter_bytes`."}, + }); + addSettingsChanges(settings_changes_history, "26.6", { {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index b40fb23ca262..2b2d06c4b1f0 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -219,6 +219,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.bloom_filter_push_down = settings[Setting::input_format_parquet_bloom_filter_push_down]; format_settings.parquet.page_filter_push_down = settings[Setting::input_format_parquet_page_filter_push_down]; format_settings.parquet.use_offset_index = settings[Setting::input_format_parquet_use_offset_index]; + format_settings.parquet.use_constant_column_optimization = settings[Setting::input_format_parquet_use_constant_column_optimization]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; format_settings.parquet.memory_low_watermark = settings[Setting::input_format_parquet_memory_low_watermark]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 728a1faba670..a91d7a0bcfdb 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -349,6 +349,7 @@ struct FormatSettings bool bloom_filter_push_down = true; bool page_filter_push_down = true; bool use_offset_index = true; + bool use_constant_column_optimization = true; bool enable_json_parsing = true; bool preserve_order = false; diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index d94b6ae42b81..a0aeac6fc683 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -40,6 +40,7 @@ namespace ProfileEvents { extern const Event ParquetRowsFilterExpression; extern const Event ParquetColumnsFilterExpression; + extern const Event ParquetConstantColumnChunks; } namespace DB::Parquet @@ -422,6 +423,8 @@ void Reader::prefilterAndInitRowGroups(const std::optionalmeta_data.statistics.__isset.null_count && column.meta->meta_data.statistics.null_count == 0; column.need_null_map = is_nullable && !null_count_is_known_to_be_zero; + + detectConstantColumn(column, primitive_columns[column_idx]); } } @@ -544,7 +547,9 @@ void Reader::initializePrefetches() /// Dictionary page. size_t dict_page_length = 0; - if (column.meta->meta_data.__isset.dictionary_page_offset) + /// A constant column chunk is materialized without reading any pages (data or + /// dictionary), so don't prefetch its dictionary page either. + if (column.meta->meta_data.__isset.dictionary_page_offset && !column.is_constant) { /// We assume that the dictionary page is immediately followed by the first data page. size_t start = size_t(column.meta->meta_data.dictionary_page_offset); @@ -592,8 +597,13 @@ void Reader::initializePrefetches() max_header_length, /*likely_to_be_used=*/ true); } + /// A constant column chunk is materialized without reading any of its pages (see + /// detectConstantColumn and decodePrimitiveColumn), so it needs neither the offset index + /// nor the column index nor the data pages. The row group already passed the key + /// condition via its min == max hyperrectangle, so page-level pruning is redundant here. + /// Offset index. - if (use_offset_index && + if (use_offset_index && !column.is_constant && column.meta->__isset.offset_index_offset && column.meta->__isset.offset_index_length) { column.offset_index_prefetch = prefetcher.registerRange( @@ -602,7 +612,8 @@ void Reader::initializePrefetches() } /// Column index. - column.use_column_index = primitive_columns[column_idx].column_index_condition + column.use_column_index = !column.is_constant + && primitive_columns[column_idx].column_index_condition && column.offset_index_prefetch && column.meta->__isset.column_index_offset && column.meta->__isset.column_index_length; if (column.use_column_index) @@ -622,10 +633,11 @@ void Reader::initializePrefetches() if (file_metadata.created_by == "parquet-mr" && !column.meta->meta_data.__isset.dictionary_page_offset && !column.meta->__isset.offset_index_offset) data_pages_extra_bytes = std::min(100ul, prefetcher.getFileSize() - size_t(column.meta->meta_data.data_page_offset) - column.data_pages_bytes); - column.data_pages_prefetch = prefetcher.registerRange( - size_t(column.meta->meta_data.data_page_offset), - column.data_pages_bytes + data_pages_extra_bytes, - /*likely_to_be_used=*/ true); + if (!column.is_constant) + column.data_pages_prefetch = prefetcher.registerRange( + size_t(column.meta->meta_data.data_page_offset), + column.data_pages_bytes + data_pages_extra_bytes, + /*likely_to_be_used=*/ true); } } @@ -1190,6 +1202,8 @@ void Reader::decodeOffsetIndex(ColumnChunk & column, const RowGroup & row_group) void Reader::determinePagesToPrefetch(ColumnChunk & column, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out) { chassert(row_subgroup.filter.rows_pass > 0); + if (column.is_constant) + return; // constant column: data pages are never read if (column.offset_index.page_locations.empty()) return; // no offset index, can't prefetch individual pages @@ -1327,8 +1341,83 @@ double Reader::estimateColumnMemoryBytesPerRow(const ColumnChunk & column, const return res; } +void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const +{ + if (!options.format.parquet.use_constant_column_optimization) + return; + /// We rely on column chunk min/max statistics being both present and decodable. + if (!column_info.decoder.allow_stats) + return; + + /// Only flat, top-level primitive columns. Nested columns (arrays, tuples, maps) and + /// physically-nullable structs carry repetition/definition level information that we would skip + /// by not reading the pages, so restrict to the simple case where "value" == "row". + if (column_info.levels.size() != 1 || column_info.group_nullable) + return; + if (column_info.levels.back().is_array || column_info.levels.back().rep != 0) + return; + + const auto & meta_data = column.meta->meta_data; + if (!meta_data.__isset.statistics) + return; + const auto & stats = meta_data.statistics; + + /// All rows must be non-null. A chunk with both a value and some nulls has two distinct logical + /// values and still needs a null map. (An all-null chunk is a separate, not-yet-handled case.) + if (!stats.__isset.null_count || stats.null_count != 0) + return; + + if (!stats.__isset.min_value || !stats.__isset.max_value) + return; + if (stats.min_value != stats.max_value) + return; + + /// For BYTE_ARRAY / FIXED_LEN_BYTE_ARRAY the writer may store truncated min/max, which could make + /// two different values compare equal. Trust min == max only when the writer marked both exact. + /// Fixed-width numeric types are never truncated, so min == max is always exact for them. + const bool may_be_truncated = + meta_data.type == parq::Type::BYTE_ARRAY || meta_data.type == parq::Type::FIXED_LEN_BYTE_ARRAY; + if (may_be_truncated + && !(stats.__isset.is_min_value_exact && stats.is_min_value_exact + && stats.__isset.is_max_value_exact && stats.is_max_value_exact)) + return; + + Field value; + column_info.decoder.decodeField(stats.min_value, /*is_max=*/ false, value); + /// decodeField leaves `value` unchanged (Null) if the physical type is unsupported for stats. + if (value.isNull()) + return; + + column.is_constant = true; + column.constant_value = std::move(value); + ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); +} + void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup) { + if (column.is_constant) + { + /// This chunk provably holds a single value in every row (see detectConstantColumn), and its + /// data pages were never fetched. Skip all decoding and hand the already-decoded value to + /// formOutputColumn, which materializes it directly in the final output type. The value is + /// in the output (post-cast) domain, so it must not go through the decoded_type column and + /// castColumn path. We still run the per-output-column bookkeeping below so the output column + /// is formed once the last of its primitive columns is done. + subchunk.is_constant = true; + subchunk.constant_value = column.constant_value; + + OutputColumnState & state = row_subgroup.output.at(column_info.idx_in_output_block); + chassert(!state.column); + size_t prev_count = state.primitive_columns_remaining.fetch_sub(1); + chassert(prev_count > 0); + if (prev_count == 1) + { + const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); + state.column = formOutputColumn(row_subgroup, output_idx.value(), row_subgroup.filter.rows_pass); + } + return; + } + /// Allocate columns for values, null map, and array offsets. size_t output_num_values_estimate = 0; @@ -2154,6 +2243,18 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out chassert(output_info.primitive_start + 1 == output_info.primitive_end); size_t primitive_idx = output_info.primitive_start; ColumnSubchunk & subchunk = row_subgroup.columns.at(primitive_idx); + + if (subchunk.is_constant) + { + /// Constant column chunk (see detectConstantColumn): materialize the single value + /// directly in the final output type. The value is already in the output (post-cast) + /// domain, so we skip the decoded_type column and the castColumn below. The chunk has no + /// nulls, so there is nothing to record in block_missing_values. + auto constant_column = output_info.output_type->createColumn(); + constant_column->insertMany(subchunk.constant_value, num_rows); + return constant_column; + } + res = std::move(subchunk.column); if (output_info.idx_in_output_block.has_value() && diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 53b598aa5524..fd224e690b2a 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -297,6 +297,13 @@ struct Reader bool use_column_index = false; bool need_null_map = false; + /// This column chunk provably holds a single repeated value in every row (see + /// detectConstantColumn). When set, we skip prefetching and decoding the data pages and + /// materialize `constant_value` directly instead. `constant_value` is the already-decoded + /// value (not the raw parquet-encoded bytes). + bool is_constant = false; + Field constant_value; + /// Prefetches. /// TODO [parquet]: Check that all handles and tokens are reset after correct stages. PrefetchHandle bloom_filter_header_prefetch; @@ -347,6 +354,13 @@ struct Reader /// Primitive column. MutableColumnPtr column; + /// Set by decodePrimitiveColumn when the source column chunk is constant (see + /// ColumnChunk::is_constant): `column` is then left empty and formOutputColumn materializes + /// `constant_value` directly in the final output type. `constant_value` is in the output + /// (post-cast) domain. + bool is_constant = false; + Field constant_value; + MutableColumnPtr null_map; /// If this primitive column is inside an array, this is the offsets for `ColumnArray`s at @@ -512,6 +526,12 @@ struct Reader void decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup); + /// If the column chunk provably holds one repeated value in every row, sets column.is_constant + /// and column.constant_value. Uses column chunk min/max statistics (Tier 1). Only applies to + /// flat, top-level primitive columns with no element nulls; see the implementation for the + /// exact conditions. + void detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const; + /// Returns mutable column because some of the recursive calls require it, /// e.g. ColumnArray::create does assumeMutable() on the nested columns. /// Moves the column out of ColumnSubchunk-s, leaving nullptrs in ColumnSubchunk::column. diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference new file mode 100644 index 000000000000..3328031ec73e --- /dev/null +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference @@ -0,0 +1,12 @@ +-- values, optimization on +42 hello 2020-01-02 03:04:05 7 1000 +-- values, optimization off (must be identical) +42 hello 2020-01-02 03:04:05 7 1000 +-- the varying column is read correctly (not treated as constant) +499500 0 999 1000 +-- filters on a constant column still work +1000 +0 +-- optimization fired only when enabled +1 +1 diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh new file mode 100755 index 000000000000..2a059de02187 --- /dev/null +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Tags: no-fasttest + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +USER_FILES_PATH=$($CLICKHOUSE_CLIENT_BINARY --query "select _path,_file from file('nonexist.txt', 'CSV', 'val1 char')" 2>&1 | grep Exception | awk '{gsub("/nonexist.txt","",$9); print $9}') +WORKING_DIR="${USER_FILES_PATH}/${CLICKHOUSE_TEST_UNIQUE_NAME}" +mkdir -p "${WORKING_DIR}" +DATA_FILE="${WORKING_DIR}/const.parquet" + +# 1000 rows, 100 rows per row group => 10 row groups. `k` varies; the other four columns each hold a +# single value in every row, so their per-chunk min/max statistics have min == max and no nulls. +# `c_dt` is written as TIMESTAMP_MILLIS and read back with a DateTime hint, exercising the +# milliseconds -> seconds stats conversion (the value is in the post-cast output domain). +${CLICKHOUSE_CLIENT} -q " + INSERT INTO FUNCTION file('${DATA_FILE}', Parquet) + SELECT + number AS k, + 42::Int64 AS c_int, + 'hello' AS c_str, + toDateTime('2020-01-02 03:04:05') AS c_dt, + 7::Nullable(Int64) AS c_nullable + FROM numbers(1000) + SETTINGS engine_file_truncate_on_insert = 1, output_format_parquet_row_group_size = 100 +" + +STRUCTURE="k UInt64, c_int Int64, c_str String, c_dt DateTime, c_nullable Nullable(Int64)" + +qid_on="${CLICKHOUSE_TEST_UNIQUE_NAME}_on" +qid_off="${CLICKHOUSE_TEST_UNIQUE_NAME}_off" + +echo "-- values, optimization on" +${CLICKHOUSE_CLIENT} --query_id="${qid_on}" -q " + SELECT c_int, c_str, c_dt, c_nullable, count() + FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') + GROUP BY 1, 2, 3, 4 +" + +echo "-- values, optimization off (must be identical)" +${CLICKHOUSE_CLIENT} --query_id="${qid_off}" -q " + SELECT c_int, c_str, c_dt, c_nullable, count() + FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') + GROUP BY 1, 2, 3, 4 + SETTINGS input_format_parquet_use_constant_column_optimization = 0 +" + +echo "-- the varying column is read correctly (not treated as constant)" +${CLICKHOUSE_CLIENT} -q "SELECT sum(k), min(k), max(k), count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}')" + +echo "-- filters on a constant column still work" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE c_int = 42" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE c_int = 43" + +echo "-- optimization fired only when enabled" +${CLICKHOUSE_CLIENT} -q " + SYSTEM FLUSH LOGS query_log; + SELECT ProfileEvents['ParquetConstantColumnChunks'] > 0 + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_on}' AND type = 'QueryFinish' AND current_database = currentDatabase(); + SELECT ProfileEvents['ParquetConstantColumnChunks'] = 0 + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_off}' AND type = 'QueryFinish' AND current_database = currentDatabase(); +" + +rm -rf "${WORKING_DIR}" From 34816a35b40c227b10f75341d9d96fed2c0701d1 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Wed, 5 Aug 2026 18:31:02 +0300 Subject: [PATCH 02/39] Parquet v3: rebalance stage budgets and charge decoded memory honestly Two independent scheduling fixes for the v3 reader on large remote files. A. Split the per-stage memory budget from the thread budget. Previously `Stage::memory_target_fraction` drove both a stage's memory watermark and its thread count (`getLimitsPerReader`), and every active stage defaulted to an equal 0.2 share. So `ColumnData` - the one stage that decodes large row groups - was capped at 0.2 of both memory and threads, and on a single large cross-region file only ~2 row groups were read/decoded ahead, leaving the link idle. `Stage` now has a separate `thread_target_fraction`; `getLimitsPerReader` takes both. `ColumnData` gets the lion's share of memory and a larger thread share, while the small, latency-bound index/bloom reads keep enough threads for parallel small reads. The split is static and wants a perf run to tune; it still couples prefetch depth to decode concurrency (decoupling those is a separate, larger change). B. Charge decoded output by its actual footprint. The memory reserved for a subchunk before decoding was an estimate (`estimateColumnMemoryBytesPerRow`) that undershoots for long strings and skewed data, so real RAM overshot the watermark and the overshoot grew with decode-ahead depth. After `decodePrimitiveColumn`, reconcile the `MemoryUsageToken` up to the real `allocatedBytes` of the decoded column, offsets and null maps (grow-only, fail-closed), so the stage counter is honest and the scheduler stops decoding ahead before RAM exceeds the cap. Both are internal scheduling/accounting changes with no query-result change. NOT YET BUILT OR PERF-TESTED; the stage split numbers are a starting point. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Formats/Impl/Parquet/ReadCommon.cpp | 12 +-- .../Formats/Impl/Parquet/ReadCommon.h | 5 +- .../Formats/Impl/Parquet/ReadManager.cpp | 79 ++++++++++++++++--- .../Formats/Impl/Parquet/ReadManager.h | 7 ++ 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp b/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp index 953562e818b4..736f8ec0ed9b 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp @@ -6,15 +6,17 @@ namespace DB::Parquet { -SharedResourcesExt::Limits SharedResourcesExt::getLimitsPerReader(const FormatParserSharedResources & parser_shared_resources, double fraction) +SharedResourcesExt::Limits SharedResourcesExt::getLimitsPerReader(const FormatParserSharedResources & parser_shared_resources, double memory_fraction, double thread_fraction) { const SharedResourcesExt & ext = *static_cast(parser_shared_resources.opaque.get()); size_t n = parser_shared_resources.num_streams.load(std::memory_order_relaxed); - fraction /= static_cast(std::max(n, size_t(1))); + /// Split each budget across the files read in parallel. + memory_fraction /= static_cast(std::max(n, size_t(1))); + thread_fraction /= static_cast(std::max(n, size_t(1))); return Limits { - .memory_low_watermark = size_t(ext.total_memory_low_watermark * fraction), - .memory_high_watermark = size_t(ext.total_memory_high_watermark * fraction), - .parsing_threads = size_t(std::max(std::lround(parser_shared_resources.parsing_runner.getMaxThreads() * fraction + .5), 1l))}; + .memory_low_watermark = size_t(ext.total_memory_low_watermark * memory_fraction), + .memory_high_watermark = size_t(ext.total_memory_high_watermark * memory_fraction), + .parsing_threads = size_t(std::max(std::lround(parser_shared_resources.parsing_runner.getMaxThreads() * thread_fraction + .5), 1l))}; } #ifdef OS_LINUX diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.h b/src/Processors/Formats/Impl/Parquet/ReadCommon.h index 76b8a0fbccd5..988c6c52b0aa 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.h +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.h @@ -50,7 +50,7 @@ struct SharedResourcesExt size_t parsing_threads; }; - static Limits getLimitsPerReader(const FormatParserSharedResources & parser_shared_resources, double fraction); + static Limits getLimitsPerReader(const FormatParserSharedResources & parser_shared_resources, double memory_fraction, double thread_fraction); }; @@ -186,6 +186,9 @@ class MemoryUsageToken val += amount; } + /// How much memory this token currently charges. + size_t charged() const { return val; } + private: ReadStage alloc_stage = ReadStage::Deallocated; size_t val = 0; diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index 3408cd99c032..f315a100c40c 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -74,17 +74,51 @@ void ReadManager::init(FormatParserSharedResourcesPtr parser_shared_resources_, stages[i].row_group_tasks_to_schedule.resize(num_row_groups); } - /// Distribute memory budget among stages. - /// The distribution is static to make sure no stage gets starved if others eat all the memory. - /// E.g. if the budget was shared among all stages, maybe PrewhereData could run far ahead and - /// The distribution is static to make sure no stage gets starved if others eat all the memory. - double sum = 0; - stages[size_t(ReadStage::NotStarted)].memory_target_fraction = 0; - stages[size_t(ReadStage::Deliver)].memory_target_fraction = 0; + /// Distribute the memory and thread budgets among stages. + /// The distribution is static to make sure no stage gets starved if others eat all the resources. + /// E.g. if the budget was shared among all stages, maybe ColumnData could run far ahead and eat + /// all the memory, starving the small index reads that other row groups need to make progress. + /// + /// Memory and threads are budgeted separately (memory_target_fraction vs thread_target_fraction): + /// the main data (ColumnData) needs most of the memory because decoded row groups are large, but + /// the small index/bloom reads are latency-bound over network and want lots of parallelism while + /// costing little memory. A single shared fraction (the previous behavior, all stages equal at + /// 0.2) forced trading one for the other: ColumnData was capped at 0.2 of the budget AND 0.2 of + /// the threads, so on a single large remote file only ~2 row groups were read/decoded ahead, + /// leaving the link idle. ColumnData now gets the lion's share of memory and a larger thread + /// share (so more row groups are in flight, keeping more reads outstanding), while the index + /// stages keep enough threads to issue their many small reads in parallel. + /// + /// These are static starting points; they still couple prefetch depth to decode concurrency (a + /// ColumnData slot both issues the reads and holds the decoded row group). Decoupling those - so + /// many compressed row groups can be in flight without as many decoded ones resident - is a + /// separate, larger change; see the design notes. The numbers below want a perf run to tune. + using S = ReadStage; + auto set_fractions = [&](S s, double memory_fraction, double thread_fraction) + { + stages[size_t(s)].memory_target_fraction = memory_fraction; + stages[size_t(s)].thread_target_fraction = thread_fraction; + }; + set_fractions(S::NotStarted, 0, 0); + set_fractions(S::BloomFilterHeader, 0.05, 1); + set_fractions(S::BloomFilterBlocksOrDictionary, 0.10, 1); + set_fractions(S::ColumnIndexAndOffsetIndex, 0.05, 1); + set_fractions(S::OffsetIndex, 0.05, 1); + set_fractions(S::ColumnData, 0.75, 3); + set_fractions(S::Deliver, 0, 0); + + double memory_sum = 0; + double thread_sum = 0; for (const Stage & stage : stages) - sum += stage.memory_target_fraction; + { + memory_sum += stage.memory_target_fraction; + thread_sum += stage.thread_target_fraction; + } for (Stage & stage : stages) - stage.memory_target_fraction /= sum; + { + stage.memory_target_fraction /= memory_sum; + stage.thread_target_fraction /= thread_sum; + } /// The NotStarted stage completed for all row groups, transition to next stage. MemoryUsageDiff diff(ReadStage::NotStarted); @@ -544,7 +578,7 @@ void ReadManager::flushMemoryUsageDiff(MemoryUsageDiff && diff) if (!should_schedule && d < 0) { const auto & stage = stages[i]; - auto limits = SharedResourcesExt::getLimitsPerReader(*parser_shared_resources, stage.memory_target_fraction); + auto limits = SharedResourcesExt::getLimitsPerReader(*parser_shared_resources, stage.memory_target_fraction, stage.thread_target_fraction); should_schedule = checkTaskSchedulingLimits( stage.memory_usage.load(std::memory_order_relaxed), 0, stage.batches_in_progress.load(std::memory_order_relaxed), 0, limits); @@ -562,7 +596,7 @@ void ReadManager::scheduleTasksIfNeeded(ReadStage stage_idx) MemoryUsageDiff diff(stage_idx); std::vector tasks; - auto limits = SharedResourcesExt::getLimitsPerReader(*parser_shared_resources, stage.memory_target_fraction); + auto limits = SharedResourcesExt::getLimitsPerReader(*parser_shared_resources, stage.memory_target_fraction, stage.thread_target_fraction); size_t memory_usage = stage.memory_usage.load(std::memory_order_relaxed); size_t batches_in_progress = stage.batches_in_progress.load(std::memory_order_relaxed); @@ -857,9 +891,28 @@ void ReadManager::runTask(Task task, bool last_in_batch, MemoryUsageDiff & diff) size_t prev_page_idx = column.data_pages_idx; chassert(task.row_subgroup_idx != UINT64_MAX); + ColumnSubchunk & subchunk = row_subgroup.columns.at(task.column_idx); reader.decodePrimitiveColumn( - column, column_info, row_subgroup.columns.at(task.column_idx), - row_group, row_subgroup); + column, column_info, subchunk, row_group, row_subgroup); + + /// The memory charged for this subchunk before decoding (`scheduleTask`) was an + /// estimate from `estimateColumnMemoryBytesPerRow`, which can undershoot for long + /// strings or skewed data. Reconcile the charge up to the actual decoded footprint so + /// the stage's memory counter is honest and the scheduler stops decoding ahead before + /// real RAM exceeds the watermark. Grow-only: never drop below the reservation. + size_t actual_bytes = 0; + if (subchunk.column) + actual_bytes += subchunk.column->allocatedBytes(); + for (const auto & offsets : subchunk.arrays_offsets) + if (offsets) + actual_bytes += offsets->allocatedBytes(); + if (subchunk.null_map) + actual_bytes += subchunk.null_map->allocatedBytes(); + if (subchunk.group_null_map) + actual_bytes += subchunk.group_null_map->allocatedBytes(); + size_t already_charged = subchunk.column_and_offsets_memory.charged(); + if (actual_bytes > already_charged) + subchunk.column_and_offsets_memory.add(actual_bytes - already_charged, &diff); for (size_t i = prev_page_idx; i < column.data_pages_idx; ++i) { diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.h b/src/Processors/Formats/Impl/Parquet/ReadManager.h index 49ac1b2942c8..e9f71d4aeb2f 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.h +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.h @@ -88,7 +88,14 @@ class ReadManager /// Tasks that are either in thread pool's queue or executing. std::atomic batches_in_progress {0}; + /// Share of the query-global memory budget for this stage. Kept separate from the thread + /// share below so a stage that needs many parallel reads but little memory (e.g. the small + /// index reads, which are latency-bound over network) is not forced to trade one for the + /// other. See ReadManager's constructor for how these are assigned and normalized. double memory_target_fraction = 1; + /// Share of the parsing thread pool for this stage (parallelism), independent of the memory + /// share above. + double thread_target_fraction = 1; /// We take advantage of the fact that each pair can have at most one group /// of tasks in flight at a time. E.g. we create tasks to read columns in subgroup n, then From f26050692bead236d37990b5c45d91894b9da345 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Wed, 5 Aug 2026 18:33:57 +0300 Subject: [PATCH 03/39] Parquet v3: reconcile decoded memory before formOutputColumn moves it Fixes the placement of the honest-accounting reconciliation from the previous commit. It ran in `runTask` after `decodePrimitiveColumn` returned, but for the common single-primitive column the function's tail already `std::move`s `subchunk.column` into the output via `formOutputColumn`, so the measurement saw a null column and was a no-op. Thread `MemoryUsageDiff &` into `decodePrimitiveColumn` and reconcile the `MemoryUsageToken` up to the real `allocatedBytes` of `subchunk.column` (plus array offsets and the group null map) at the end of decoding, just before the bookkeeping that may move the column out. Grow-only, fail-closed. Matches the earlier `parquet/honest-memory-cap` prototype. Still not built or perf-tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Formats/Impl/Parquet/ReadManager.cpp | 23 ++----------------- .../Formats/Impl/Parquet/Reader.cpp | 19 ++++++++++++++- src/Processors/Formats/Impl/Parquet/Reader.h | 2 +- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index f315a100c40c..00adb82f96a7 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -891,28 +891,9 @@ void ReadManager::runTask(Task task, bool last_in_batch, MemoryUsageDiff & diff) size_t prev_page_idx = column.data_pages_idx; chassert(task.row_subgroup_idx != UINT64_MAX); - ColumnSubchunk & subchunk = row_subgroup.columns.at(task.column_idx); reader.decodePrimitiveColumn( - column, column_info, subchunk, row_group, row_subgroup); - - /// The memory charged for this subchunk before decoding (`scheduleTask`) was an - /// estimate from `estimateColumnMemoryBytesPerRow`, which can undershoot for long - /// strings or skewed data. Reconcile the charge up to the actual decoded footprint so - /// the stage's memory counter is honest and the scheduler stops decoding ahead before - /// real RAM exceeds the watermark. Grow-only: never drop below the reservation. - size_t actual_bytes = 0; - if (subchunk.column) - actual_bytes += subchunk.column->allocatedBytes(); - for (const auto & offsets : subchunk.arrays_offsets) - if (offsets) - actual_bytes += offsets->allocatedBytes(); - if (subchunk.null_map) - actual_bytes += subchunk.null_map->allocatedBytes(); - if (subchunk.group_null_map) - actual_bytes += subchunk.group_null_map->allocatedBytes(); - size_t already_charged = subchunk.column_and_offsets_memory.charged(); - if (actual_bytes > already_charged) - subchunk.column_and_offsets_memory.add(actual_bytes - already_charged, &diff); + column, column_info, row_subgroup.columns.at(task.column_idx), + row_group, row_subgroup, diff); for (size_t i = prev_page_idx; i < column.data_pages_idx; ++i) { diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index a0aeac6fc683..a790497ffae8 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1393,7 +1393,7 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); } -void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup) +void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff) { if (column.is_constant) { @@ -1577,6 +1577,23 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn chassert(subchunk.column->getDataType() == column_info.output_type->getColumnType()); + /// The memory charged for this subchunk in scheduleTask was an estimate + /// (estimateColumnMemoryBytesPerRow), which can undershoot for long strings or skewed data, so + /// real RAM would overshoot the watermark and the overshoot compounds with decode-ahead depth. + /// Reconcile the charge up to the actual decoded footprint now, before formOutputColumn (below) + /// may move `subchunk.column` into the output, so the stage counter is honest and the scheduler + /// stops decoding ahead before real RAM exceeds the cap. Grow-only: never drop below the + /// reservation (fail-closed). + size_t actual_bytes = subchunk.column->allocatedBytes(); + for (const auto & offsets : subchunk.arrays_offsets) + if (offsets) + actual_bytes += offsets->allocatedBytes(); + if (subchunk.group_null_map) + actual_bytes += subchunk.group_null_map->allocatedBytes(); + size_t already_charged = subchunk.column_and_offsets_memory.charged(); + if (actual_bytes > already_charged) + subchunk.column_and_offsets_memory.add(actual_bytes - already_charged, &diff); + OutputColumnState & state = row_subgroup.output.at(column_info.idx_in_output_block); chassert(!state.column); size_t prev_count = state.primitive_columns_remaining.fetch_sub(1); diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index fd224e690b2a..8a3d8df34582 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -524,7 +524,7 @@ struct Reader /// Guess how much memory ColumnSubchunk::{column, arrays_offsets} will use, per row. double estimateColumnMemoryBytesPerRow(const ColumnChunk & column, const RowGroup & row_group, const PrimitiveColumnInfo & column_info) const; - void decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup); + void decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff); /// If the column chunk provably holds one repeated value in every row, sets column.is_constant /// and column.constant_value. Uses column chunk min/max statistics (Tier 1). Only applies to From 2a20ab96dae6b890f84b4cbe21139cae278ed26c Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 05:00:34 +0300 Subject: [PATCH 04/39] Parquet v3: fix constant-column detection gates (never fired) Two gate bugs made detectConstantColumn reject every column, so the optimization never triggered on real files. Found by building and testing against CH-written Parquet. 1. Null gate. The CH writer omits `null_count` for physically non-nullable (`REQUIRED`) columns, but the gate required `null_count` to be present and zero, rejecting every non-nullable column. A `REQUIRED` column can have no nulls regardless of the statistic, so only require zero `null_count` when the column is physically nullable (max definition level > 0). 2. Structural gate. `levels[0]` is a synthetic root sentinel with `is_array = true`, so a flat `REQUIRED` column's `levels.back()` IS that root (`size() == 1`, `is_array == true`) and a `Nullable` column has `size() == 2`. The old `levels.size() == 1 && !levels.back().is_array` test therefore rejected exactly the flat columns it meant to accept. Replace with the correct flatness signals (`levels.back().rep == 0` and `max_array_def == 0`), keep the `group_nullable` exclusion, and require the output column to be primitive (top-level, not a Tuple/Map/Array leaf) via `output_columns`. Verified on a CH-written file (1M rows, constant Int64/String/DateTime/ Nullable columns): the optimization now fires (`ParquetConstantColumnChunks` = columns x row groups), results are correct (including the TIMESTAMP_MILLIS -> DateTime seconds path), reading the constant columns drops `ParquetPrefetcherReadRandomRead` 10 -> 1 and `ParquetDecodingTasks` 96 -> 64, and a varying column is correctly not treated as constant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Formats/Impl/Parquet/Reader.cpp | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index a790497ffae8..ef804cb2ef15 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1349,12 +1349,19 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf if (!column_info.decoder.allow_stats) return; - /// Only flat, top-level primitive columns. Nested columns (arrays, tuples, maps) and - /// physically-nullable structs carry repetition/definition level information that we would skip - /// by not reading the pages, so restrict to the simple case where "value" == "row". - if (column_info.levels.size() != 1 || column_info.group_nullable) + /// Only flat, top-level primitive columns, so that one parquet value maps 1:1 to one output row + /// and formOutputColumn can materialize the value directly. Exclude: + /// - arrays (leaf repetition level > 0, or any array level: max_array_def > 0), + /// - physically-nullable structs read as Nullable(Tuple(...)) (group_nullable), + /// - leaves nested inside a Tuple/Map/Array output column (the output column is not primitive). + /// A plain Nullable(T) is fine: it adds a definition level but no repetition, and its output + /// column is still primitive; the no-nulls check below and the output_nullable wrap handle it. + if (column_info.levels.back().rep != 0 || column_info.max_array_def != 0 || column_info.group_nullable) return; - if (column_info.levels.back().is_array || column_info.levels.back().rep != 0) + if (column_info.idx_in_output_block >= sample_block_to_output_columns_idx.size()) + return; + const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); + if (!output_idx.has_value() || !output_columns[output_idx.value()].is_primitive) return; const auto & meta_data = column.meta->meta_data; @@ -1362,9 +1369,13 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf return; const auto & stats = meta_data.statistics; - /// All rows must be non-null. A chunk with both a value and some nulls has two distinct logical + /// All rows must be non-null. If the parquet column is physically nullable (max definition level + /// > 0), require the statistics to prove zero nulls. If it is REQUIRED (definition level 0) there + /// can be no nulls, so the null_count statistic - which writers commonly omit for non-nullable + /// columns - is unnecessary. A chunk that mixes the value with nulls has two distinct logical /// values and still needs a null map. (An all-null chunk is a separate, not-yet-handled case.) - if (!stats.__isset.null_count || stats.null_count != 0) + bool physically_nullable = column_info.levels.back().def > 0; + if (physically_nullable && (!stats.__isset.null_count || stats.null_count != 0)) return; if (!stats.__isset.min_value || !stats.__isset.max_value) From 114640eeaf6d66e774965d0e24ba098c3b8c4d0e Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 10:47:26 +0300 Subject: [PATCH 05/39] Parquet v3: decouple compressed prefetch from decode (ColumnDataPrefetch stage) Adds a ColumnDataPrefetch pipeline stage between OffsetIndex and ColumnData. It runs `determinePagesToPrefetch` and issues the compressed data-page reads (`startPrefetch`) but does not decode; ColumnData then only decodes from the already-in-flight buffers. The two stages have separate memory budgets: ColumnDataPrefetch gets a large share (compressed row groups are cheap, ~tens of MB) so many row groups can have their reads outstanding, while ColumnData gets a bounded share (decoded row groups are large, ~hundreds of MB) that caps how many are decoded/resident at once. Because row groups are independent and their reads run in the Prefetcher's own io pool (not the parsing threads), fetch depth is now decoupled from decode-ahead depth - the fetch-deep / decode-shallow mode that finding #5 called for. Within a row group subgroups stay sequential, so `determinePagesToPrefetch`'s in-order requirement is preserved. The compressed-read memory is charged to the ColumnDataPrefetch stage via startPrefetch and released when ColumnData resets the prefetch handles (the handle records its allocating stage, so the release credits the right budget regardless of which stage's diff performs it). Verified on the Debug build: reads are correct with and without PREWHERE (3M-row multi-row-group file), the constant-column optimization still fires, and there is no deadlock. Perf/prefetch-depth gains need a high-RTT remote (S3) run to observe. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Formats/Impl/Parquet/ReadCommon.h | 4 ++ .../Formats/Impl/Parquet/ReadManager.cpp | 53 +++++++++++++++---- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.h b/src/Processors/Formats/Impl/Parquet/ReadCommon.h index 988c6c52b0aa..4d1652d07dc8 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.h +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.h @@ -88,6 +88,10 @@ enum class ReadStage ColumnIndexAndOffsetIndex, OffsetIndex, + /// Issues the compressed data-page reads (startPrefetch) but does not decode. Charged to its own + /// memory budget so many row groups can have their reads in flight (deep prefetch) while only a + /// few are decoded at once (ColumnData). Decouples fetch depth from decode-ahead depth. + ColumnDataPrefetch, ColumnData, Deliver, diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index 00adb82f96a7..8551dcb8ec33 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -104,7 +104,14 @@ void ReadManager::init(FormatParserSharedResourcesPtr parser_shared_resources_, set_fractions(S::BloomFilterBlocksOrDictionary, 0.10, 1); set_fractions(S::ColumnIndexAndOffsetIndex, 0.05, 1); set_fractions(S::OffsetIndex, 0.05, 1); - set_fractions(S::ColumnData, 0.75, 3); + /// Compressed data-page reads: a large budget so many row groups can prefetch ahead (each + /// compressed row group is cheap). Few threads - issuing startPrefetch is cheap and the actual + /// reads run in the Prefetcher's own io pool, not the parsing threads. + set_fractions(S::ColumnDataPrefetch, 0.45, 1); + /// Decode: the expensive resource (decoded row groups are large), so a bounded memory budget and + /// the bulk of the decode threads. This bounds how many row groups are decoded/resident at once, + /// independently of how deep the compressed prefetch runs above. + set_fractions(S::ColumnData, 0.30, 3); set_fractions(S::Deliver, 0, 0); double memory_sum = 0; @@ -173,6 +180,7 @@ void ReadManager::finishRowGroupStage(size_t row_group_idx, ReadStage stage, Mem switch (stage) { case ReadStage::NotStarted: + case ReadStage::ColumnDataPrefetch: case ReadStage::ColumnData: case ReadStage::Deliver: chassert(false); @@ -329,9 +337,10 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou } else { - LOG_TEST(getLogger("ParquetReadManager"), "addTasksToReadColumns: added ColumnData: i={} step_idx={} row_group_idx={} row_subgroup_idx={}", i, step_idx, row_group_idx, row_subgroup_idx); + /// `stage` is ColumnDataPrefetch (issue reads) or ColumnData (decode). + LOG_TEST(getLogger("ParquetReadManager"), "addTasksToReadColumns: added {}: i={} step_idx={} row_group_idx={} row_subgroup_idx={}", magic_enum::enum_name(stage), i, step_idx, row_group_idx, row_subgroup_idx); add_tasks.push_back(Task { - .stage = ReadStage::ColumnData, + .stage = stage, .step_idx = step_idx, .row_group_idx = row_group_idx, .row_subgroup_idx = row_subgroup_idx, @@ -341,8 +350,8 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou if (add_tasks.empty() && is_offset_index) { - /// Don't need to read offset index, move on to next stage (ColumnData). - stage = ReadStage::ColumnData; + /// Don't need to read offset index, move on to the next stage (ColumnDataPrefetch). + stage = ReadStage::ColumnDataPrefetch; continue; } @@ -353,7 +362,7 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou /// (RowSubgroup.filter.memory) work correctly when PREWHERE expression doesn't use any /// columns (note: the expression may still be nontrivial, e.g. `rand()%2=0`).) add_tasks.push_back(Task { - .stage = ReadStage::ColumnData, + .stage = stage, .step_idx = step_idx, .row_group_idx = row_group_idx, .row_subgroup_idx = row_subgroup_idx, @@ -454,6 +463,13 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro case ReadStage::ColumnIndexAndOffsetIndex: case ReadStage::OffsetIndex: { + /// Prerequisites read; issue the compressed data-page reads (but don't decode yet). + addTasksToReadColumns(row_group_idx, row_subgroup_idx, ReadStage::ColumnDataPrefetch, step_idx, diff); + return; + } + case ReadStage::ColumnDataPrefetch: + { + /// Data-page reads issued (in flight in the Prefetcher's io pool); now decode. addTasksToReadColumns(row_group_idx, row_subgroup_idx, ReadStage::ColumnData, step_idx, diff); return; } @@ -749,12 +765,16 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif case ReadStage::OffsetIndex: prefetches.push_back(&column.offset_index_prefetch); break; - case ReadStage::ColumnData: + case ReadStage::ColumnDataPrefetch: { RowSubgroup & row_subgroup = row_group.subgroups.at(task.row_subgroup_idx); - ColumnSubchunk & subchunk = row_subgroup.columns.at(task.column_idx); if (row_subgroup.filter.rows_pass == 0) break; + /// Determine which data pages this subgroup needs and queue their reads. The + /// startPrefetch at the end of this function issues them against the Prefetcher's io + /// pool and charges the compressed bytes to the ColumnDataPrefetch stage budget - + /// separate from the decoded-output budget (ColumnData) - so many row groups can have + /// their reads in flight (deep prefetch) while only a few are decoded at once. reader.determinePagesToPrefetch(column, row_subgroup, row_group, prefetches); /// Side note: would be nice to avoid reading the dictionary if all dictionary-encoded @@ -771,7 +791,17 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif { prefetches.push_back(&column.data_pages_prefetch); } - + break; + } + case ReadStage::ColumnData: + { + RowSubgroup & row_subgroup = row_group.subgroups.at(task.row_subgroup_idx); + ColumnSubchunk & subchunk = row_subgroup.columns.at(task.column_idx); + if (row_subgroup.filter.rows_pass == 0) + break; + /// The data-page reads were already issued in ColumnDataPrefetch (and are in flight or + /// done in the Prefetcher). Here we only reserve the estimated decoded-output memory + /// against the ColumnData budget; runTask then decodes from those buffers. double bytes_per_row = reader.estimateColumnMemoryBytesPerRow(column, row_group, reader.primitive_columns.at(task.column_idx)); size_t column_memory = static_cast(bytes_per_row * static_cast(row_subgroup.filter.rows_pass)); subchunk.column_and_offsets_memory = MemoryUsageToken(column_memory, &diff); @@ -876,6 +906,11 @@ void ReadManager::runTask(Task task, bool last_in_batch, MemoryUsageDiff & diff) reader.decodeOffsetIndex(column, row_group); column.offset_index_prefetch.reset(&diff); break; + case ReadStage::ColumnDataPrefetch: + /// The compressed data-page reads were already issued in scheduleTask (startPrefetch) + /// and proceed asynchronously in the Prefetcher's io pool. Nothing to do here; the + /// subgroup advances to ColumnData, which decodes from those buffers. + break; case ReadStage::ColumnData: { RowSubgroup & row_subgroup = row_group.subgroups.at(task.row_subgroup_idx); From a019cd70c0182f0bce461bd11c4d5d4e004d82de Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 13:33:32 +0300 Subject: [PATCH 06/39] Parquet v3: bandwidth back-pressure for compressed prefetch (finding #4) Adds read back-pressure on the ColumnDataPrefetch stage: once the compressed data already in flight covers more than `input_format_parquet_prefetch_bandwidth_hide_seconds` of the measured read throughput, stop prefetching further ahead. Beyond that point the storage link is already fed, so extra compressed buffering only wastes memory without improving throughput (the "+21 GB RAM for +3.6 s" case in finding #4). The Prefetcher now tracks completed-read throughput (bytes since init / elapsed, `averageThroughputBytesPerSec`). The scheduler compares it against the ColumnDataPrefetch stage's in-flight compressed bytes (that stage's memory usage) and stops admitting more prefetch tasks when the in-flight bytes exceed throughput x hide_seconds. The privileged-task escape (lowest incomplete row group is always schedulable) still applies, so back-pressure can never deadlock. Defaults to 0 (disabled): the throughput heuristic and the hide-seconds target can only be validated on a high-RTT remote (S3) run, and on local fast IO the estimate is meaningless, so it is opt-in for cluster tuning. With C's ColumnDataPrefetch memory budget already providing a static cap on compressed buffering, this setting makes that cap adaptive. Verified on the Debug build: results are correct with the setting off (default) and with an aggressively small value (0.001s) that forces heavy throttling - no deadlock, identical results. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Core/FormatFactorySettings.h | 3 +++ src/Core/SettingsChangesHistory.cpp | 1 + src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 1 + .../Formats/Impl/Parquet/Prefetcher.cpp | 13 +++++++++++ .../Formats/Impl/Parquet/Prefetcher.h | 10 +++++++++ .../Formats/Impl/Parquet/ReadManager.cpp | 22 +++++++++++++++++++ 7 files changed, 51 insertions(+) diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index efea4bddcf4d..de43d5787fee 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -209,6 +209,9 @@ Minor tweak to how pages are read from parquet file when no page filtering is us )", 0) \ DECLARE(Bool, input_format_parquet_use_constant_column_optimization, true, R"( When a Parquet column chunk provably holds a single value in every row (according to its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages. +)", 0) \ + DECLARE(Double, input_format_parquet_prefetch_bandwidth_hide_seconds, 0, R"( +Read back-pressure for the Parquet v3 reader. When greater than zero, stop prefetching more compressed data pages ahead of decoding once the in-flight compressed bytes exceed this many seconds' worth of the measured read throughput (i.e. once the storage link is kept busy). Prevents buffering compressed data far beyond what bandwidth can consume. 0 disables the back-pressure (compressed prefetch is then bounded only by its memory budget). )", 0) \ DECLARE(Bool, input_format_parquet_verify_checksums, true, R"( Verify page checksums when reading parquet files. diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 07bdc0a1bd1f..e13231c8e3d8 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -69,6 +69,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_spatial_filter_push_down", false, true, "New setting: skip GeoParquet row groups and pages based on spatial predicates and bounding box statistics"}, {"use_text_index_negative_tokens_cache", false, true, "New setting to cache absent text index tokens and avoid repeated dictionary lookups."}, {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, + {"input_format_parquet_prefetch_bandwidth_hide_seconds", 0., 0., "New setting: read back-pressure for the Parquet v3 reader; stop prefetching compressed data pages ahead of decoding once in-flight compressed bytes exceed this many seconds of measured throughput. 0 (default) disables it."}, }); addSettingsChanges(settings_changes_history, "26.7", { diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index 2b2d06c4b1f0..c964cbd6a5d6 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -220,6 +220,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.page_filter_push_down = settings[Setting::input_format_parquet_page_filter_push_down]; format_settings.parquet.use_offset_index = settings[Setting::input_format_parquet_use_offset_index]; format_settings.parquet.use_constant_column_optimization = settings[Setting::input_format_parquet_use_constant_column_optimization]; + format_settings.parquet.prefetch_bandwidth_hide_seconds = settings[Setting::input_format_parquet_prefetch_bandwidth_hide_seconds]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; format_settings.parquet.memory_low_watermark = settings[Setting::input_format_parquet_memory_low_watermark]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index a91d7a0bcfdb..5ecd73920853 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -350,6 +350,7 @@ struct FormatSettings bool page_filter_push_down = true; bool use_offset_index = true; bool use_constant_column_optimization = true; + double prefetch_bandwidth_hide_seconds = 0; bool enable_json_parsing = true; bool preserve_order = false; diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index 1141cfe870a2..d4c617e77d88 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -34,6 +34,18 @@ void Prefetcher::init(ReadBuffer * reader_, const ReadOptions & options, FormatP parser_shared_resources = parser_shared_resources_; determineReadModeAndFileSize(reader_, options); range_sets.resize(1); + read_start_time = std::chrono::steady_clock::now(); +} + +double Prefetcher::averageThroughputBytesPerSec() const +{ + double seconds = std::chrono::duration(std::chrono::steady_clock::now() - read_start_time).count(); + size_t bytes = total_bytes_read.load(std::memory_order_relaxed); + /// Too little read / too little time elapsed to estimate: report "unknown" so back-pressure + /// fails open (does not throttle). + if (seconds < 0.05 || bytes < (1u << 20)) + return 0; + return static_cast(bytes) / seconds; } Prefetcher::~Prefetcher() @@ -522,6 +534,7 @@ Prefetcher::Task::State Prefetcher::runTask(Task * task) task->buf.resize(task->length); readSync(task->buf.data(), task->length, task->offset); } + total_bytes_read.fetch_add(task->length, std::memory_order_relaxed); } catch (...) { diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index 40796dd10342..c9b5f5c02f05 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include @@ -63,6 +65,10 @@ class Prefetcher size_t getFileSize() const { return file_size; } + /// Average completed-read throughput (bytes/sec) since init, or 0 if not enough has been read to + /// estimate. Used for read back-pressure (input_format_parquet_prefetch_bandwidth_hide_seconds). + double averageThroughputBytesPerSec() const; + private: friend class PrefetchHandle; @@ -179,6 +185,10 @@ class Prefetcher size_t min_bytes_for_seek{}; size_t bytes_per_read_task{}; + /// Total bytes read by completed tasks, and when reading started, for throughput estimation. + std::atomic total_bytes_read{0}; + std::chrono::steady_clock::time_point read_start_time{}; + std::shared_ptr shutdown = std::make_shared(); /// Locked when creating a Task. diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index 8551dcb8ec33..0698abc070a3 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -616,6 +616,20 @@ void ReadManager::scheduleTasksIfNeeded(ReadStage stage_idx) size_t memory_usage = stage.memory_usage.load(std::memory_order_relaxed); size_t batches_in_progress = stage.batches_in_progress.load(std::memory_order_relaxed); + /// Read back-pressure (finding #4): once the compressed data already in flight covers more than + /// `prefetch_bandwidth_hide_seconds` of the measured read throughput, the storage link is fed, so + /// stop prefetching further ahead - extra compressed buffering would only waste memory without + /// improving throughput. The compressed in-flight bytes are exactly this stage's memory usage. + /// Fail-open: 0 (disabled), or unknown throughput, means no throttle. The privileged-task escape + /// below still applies, so this can never deadlock. + double prefetch_ahead_bytes_limit = 0; // 0 = no back-pressure + if (stage_idx == ReadStage::ColumnDataPrefetch && reader.options.format.parquet.prefetch_bandwidth_hide_seconds > 0) + { + double throughput = reader.prefetcher.averageThroughputBytesPerSec(); + if (throughput > 0) + prefetch_ahead_bytes_limit = throughput * reader.options.format.parquet.prefetch_bandwidth_hide_seconds; + } + LOG_TEST(getLogger("ParquetReadManager"), "scheduleTasksIfNeeded: stage={} memory_usage={} batches_in_progress={} limits: mem_low={} mem_high={} threads={}", magic_enum::enum_name(stage_idx), memory_usage, batches_in_progress, limits.memory_low_watermark, limits.memory_high_watermark, limits.parsing_threads); @@ -649,6 +663,14 @@ void ReadManager::scheduleTasksIfNeeded(ReadStage stage_idx) bool can_schedule = checkTaskSchedulingLimits( memory_usage, size_t(diff.by_stage[size_t(stage_idx)]), batches_in_progress, tasks.size(), limits); + /// Bandwidth back-pressure: hold off further prefetch when the link is already fed. + if (can_schedule && prefetch_ahead_bytes_limit > 0) + { + double compressed_in_flight = static_cast(memory_usage) + + static_cast(std::max(0, diff.by_stage[size_t(stage_idx)])); + if (compressed_in_flight >= prefetch_ahead_bytes_limit) + can_schedule = false; + } bool is_privileged = is_privileged_task(row_group_idx); LOG_TEST(getLogger("ParquetReadManager"), "scheduleTasksIfNeeded: stage={} rg={} can_schedule={} is_privileged={}", magic_enum::enum_name(stage_idx), row_group_idx, can_schedule, is_privileged); From a3ee936f3db76d9ae35dadd28f7c35776e5c6b31 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 14:41:35 +0300 Subject: [PATCH 07/39] Parquet v3: adapt to antalya-26.6 (drop Nullable(Tuple) group-null refs) Port adaptation: antalya-26.6 predates the Nullable(Tuple) support on master, so PrimitiveColumnInfo::group_nullable and ColumnSubchunk::group_null_map do not exist there. Remove those references from the constant-column detection gate and the decoded-memory reconciliation. Both are safe: nested-in-nullable-struct leaves are already excluded from the constant-column optimization by the `output_columns[...].is_primitive` guard, and the group null map (when it would exist) is negligible in the memory reconciliation. Also resolved during the rebase onto antalya-26.6: - ProfileEvents/Reader: keep only the new ParquetConstantColumnChunks event (antalya has no ParquetPrunedPages). - Column index uses antalya's singular `column_index_condition`. - Dropped the master-only `pruningMemoryReservation` (antalya's applyBloomAndDictionaryFilters takes no reservation). NOT YET COMPILED against antalya-26.6. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Processors/Formats/Impl/Parquet/Reader.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index ef804cb2ef15..2bbfa9ed1e76 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1352,11 +1352,11 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf /// Only flat, top-level primitive columns, so that one parquet value maps 1:1 to one output row /// and formOutputColumn can materialize the value directly. Exclude: /// - arrays (leaf repetition level > 0, or any array level: max_array_def > 0), - /// - physically-nullable structs read as Nullable(Tuple(...)) (group_nullable), - /// - leaves nested inside a Tuple/Map/Array output column (the output column is not primitive). + /// - leaves nested inside a Tuple/Map/Array output column (the output column is not primitive; + /// this also covers physically-nullable structs, whose leaves are not primitive outputs). /// A plain Nullable(T) is fine: it adds a definition level but no repetition, and its output /// column is still primitive; the no-nulls check below and the output_nullable wrap handle it. - if (column_info.levels.back().rep != 0 || column_info.max_array_def != 0 || column_info.group_nullable) + if (column_info.levels.back().rep != 0 || column_info.max_array_def != 0) return; if (column_info.idx_in_output_block >= sample_block_to_output_columns_idx.size()) return; @@ -1599,8 +1599,6 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn for (const auto & offsets : subchunk.arrays_offsets) if (offsets) actual_bytes += offsets->allocatedBytes(); - if (subchunk.group_null_map) - actual_bytes += subchunk.group_null_map->allocatedBytes(); size_t already_charged = subchunk.column_and_offsets_memory.charged(); if (actual_bytes > already_charged) subchunk.column_and_offsets_memory.add(actual_bytes - already_charged, &diff); From ac2fe8e22a4fc22056d5fbe357f6719ec9fb76b4 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 15:03:36 +0300 Subject: [PATCH 08/39] DCO Remediation Commit for UnamedRus I, UnamedRus , hereby add my Signed-off-by to this commit: c6b70b3155c96a5035bd121aea72c876b326b040 I, UnamedRus , hereby add my Signed-off-by to this commit: 34816a35b40c227b10f75341d9d96fed2c0701d1 I, UnamedRus , hereby add my Signed-off-by to this commit: f26050692bead236d37990b5c45d91894b9da345 I, UnamedRus , hereby add my Signed-off-by to this commit: 2a20ab96dae6b890f84b4cbe21139cae278ed26c I, UnamedRus , hereby add my Signed-off-by to this commit: 114640eeaf6d66e774965d0e24ba098c3b8c4d0e I, UnamedRus , hereby add my Signed-off-by to this commit: a019cd70c0182f0bce461bd11c4d5d4e004d82de I, UnamedRus , hereby add my Signed-off-by to this commit: a3ee936f3db76d9ae35dadd28f7c35776e5c6b31 Signed-off-by: UnamedRus From 77c2c72fdec8028d2331d3cf2ba00041d8ea0554 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 16:19:30 +0300 Subject: [PATCH 09/39] Parquet v3: drop stray orphan setting from SettingsChangesHistory Commit c6b70b3155c accidentally added a `unique_key_probe_implementation` entry to `SettingsChangesHistory.cpp` under version 26.8. That setting is not registered anywhere in `Settings.cpp`, so `02324_compatibility_setting` failed with `UNKNOWN_SETTING` when applying `compatibility` to old versions. The entry is unrelated to the parquet constant-column work; remove it. CI: https://github.com/Altinity/ClickHouse/actions/runs/31099889198/job/92610989464 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Core/SettingsChangesHistory.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index e13231c8e3d8..abd986fbc9f0 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -42,7 +42,6 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() addSettingsChanges(settings_changes_history, "26.8", { {"max_insert_threads", 1, 0, "Changed the default from 1 (no parallel execution) to auto (0), which resolves to the number of CPU cores available to the server, reduced under memory pressure via `max_insert_threads_min_free_memory_per_thread`. This parallelizes `INSERT SELECT` by default. Set to 1 to restore the previous single-threaded behavior."}, - {"unique_key_probe_implementation", "auto", "auto", "New setting: selects the UNIQUE KEY probe implementation (currently only the simple baseline exists)"}, {"s3_base", "", "", "New setting to specify the base URL for resolving relative URLs in the s3 table function and the S3 table engine."}, {"use_query_condition_cache_for_top_k", false, false, "New setting to gate the query condition cache for `ORDER BY ... LIMIT n` (TopK) reads; disabled by default."}, {"use_projection_index_in_read_pools", false, false, "New setting to drop mark ranges fully filtered out by a projection index before read tasks are created in MergeTree read pools."}, From f37f947c634a176446cbd5be61d4c2ae54212fa8 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 17:16:35 +0300 Subject: [PATCH 10/39] Parquet v3: materialize all-null column chunks from statistics Extends the constant-column optimization to the all-null case. When a Parquet column chunk provably holds only nulls - its `null_count` statistic equals `num_values` on a physically nullable leaf - the reader no longer fetches or decodes that chunk's dictionary or data pages. `detectConstantColumn` marks the chunk constant (and `is_all_null`), and `formOutputColumn` materializes the result directly: `Null` for a Nullable output, or the output default when `input_format_null_as_default` substitutes nulls for a non-nullable output. A non-nullable output without null substitution cannot represent the result, so such a chunk is left to the normal decode path. Unlike the single-value case this needs no value decode, so it sidesteps the min/max exactness and `BYTE_ARRAY` truncation checks entirely; it only reads the `null_count` count. `formOutputColumn` records every row of an all-null chunk in `block_missing_values` (the single-value case has no nulls and records nothing), matching the normal decode path's null-map bookkeeping so `input_format_null_as_default` stays correct. All-null wide chunks are common in schema-evolved files (a column added later is all-null in older row groups), so this skips dictionary, data page, offset index and column index reads for a frequent real-world shape. Reuses the existing `input_format_parquet_use_constant_column_optimization` setting and the `ParquetConstantColumnChunks` ProfileEvent. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../Formats/Impl/Parquet/Reader.cpp | 43 ++++++++++- src/Processors/Formats/Impl/Parquet/Reader.h | 15 +++- ...uet_all_null_column_optimization.reference | 14 ++++ ...12_parquet_all_null_column_optimization.sh | 73 +++++++++++++++++++ 4 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/queries/0_stateless/04812_parquet_all_null_column_optimization.reference create mode 100755 tests/queries/0_stateless/04812_parquet_all_null_column_optimization.sh diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 2bbfa9ed1e76..17c35c7c2818 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1369,12 +1369,38 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf return; const auto & stats = meta_data.statistics; + bool physically_nullable = column_info.levels.back().def > 0; + + /// All-null chunk: every row is null (null_count == num_values). This is provable only for a + /// physically nullable leaf (a REQUIRED column can have no nulls), and only when the writer + /// emitted the null_count statistic. No value is decoded at all, so this skips the min/max + /// exactness and truncation concerns below. We materialize `Null` for a Nullable output, or the + /// output default when `null_as_default` substitutes nulls for a non-nullable output; a + /// non-nullable output without null substitution cannot represent the result, so we leave the + /// chunk to the normal decode path (which errors on the null). formOutputColumn records every + /// row in block_missing_values for this case. + if (physically_nullable && stats.__isset.null_count && stats.null_count == meta_data.num_values + && meta_data.num_values > 0) + { + const bool null_as_default = options.format.null_as_default && !column_info.output_nullable; + if (column_info.output_nullable) + column.constant_value = Null{}; + else if (null_as_default) + column.constant_value = column_info.output_type->getDefault(); + else + return; + + column.is_constant = true; + column.is_all_null = true; + ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); + return; + } + /// All rows must be non-null. If the parquet column is physically nullable (max definition level /// > 0), require the statistics to prove zero nulls. If it is REQUIRED (definition level 0) there /// can be no nulls, so the null_count statistic - which writers commonly omit for non-nullable /// columns - is unnecessary. A chunk that mixes the value with nulls has two distinct logical - /// values and still needs a null map. (An all-null chunk is a separate, not-yet-handled case.) - bool physically_nullable = column_info.levels.back().def > 0; + /// values and still needs a null map. if (physically_nullable && (!stats.__isset.null_count || stats.null_count != 0)) return; @@ -1416,6 +1442,7 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn /// is formed once the last of its primitive columns is done. subchunk.is_constant = true; subchunk.constant_value = column.constant_value; + subchunk.is_all_null = column.is_all_null; OutputColumnState & state = row_subgroup.output.at(column_info.idx_in_output_block); chassert(!state.column); @@ -2274,10 +2301,18 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out { /// Constant column chunk (see detectConstantColumn): materialize the single value /// directly in the final output type. The value is already in the output (post-cast) - /// domain, so we skip the decoded_type column and the castColumn below. The chunk has no - /// nulls, so there is nothing to record in block_missing_values. + /// domain, so we skip the decoded_type column and the castColumn below. auto constant_column = output_info.output_type->createColumn(); constant_column->insertMany(subchunk.constant_value, num_rows); + + /// An all-null chunk must record every row in block_missing_values, matching the normal + /// decode path (which records nulls from the null map); needed for + /// input_format_null_as_default. The single-value case has no nulls, so records nothing. + if (subchunk.is_all_null + && output_info.idx_in_output_block.has_value() + && *output_info.idx_in_output_block < row_subgroup.block_missing_values.getNumColumns()) + row_subgroup.block_missing_values.setBits(*output_info.idx_in_output_block, num_rows); + return constant_column; } diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 8a3d8df34582..49bc332d4661 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -303,6 +303,11 @@ struct Reader /// value (not the raw parquet-encoded bytes). bool is_constant = false; Field constant_value; + /// Sub-case of `is_constant`: the chunk is provably all-null (null_count == num_values). + /// `constant_value` is then `Null` for a Nullable output, or the output default when + /// `null_as_default` substitutes nulls for a non-nullable output. formOutputColumn also + /// records every row in block_missing_values (the plain constant case has no nulls). + bool is_all_null = false; /// Prefetches. /// TODO [parquet]: Check that all handles and tokens are reset after correct stages. @@ -360,6 +365,8 @@ struct Reader /// (post-cast) domain. bool is_constant = false; Field constant_value; + /// Mirror of ColumnChunk::is_all_null (see there). + bool is_all_null = false; MutableColumnPtr null_map; @@ -527,9 +534,11 @@ struct Reader void decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff); /// If the column chunk provably holds one repeated value in every row, sets column.is_constant - /// and column.constant_value. Uses column chunk min/max statistics (Tier 1). Only applies to - /// flat, top-level primitive columns with no element nulls; see the implementation for the - /// exact conditions. + /// and column.constant_value. Two sub-cases, both from column chunk statistics (Tier 1): + /// - a single non-null value in every row (min_value == max_value, no nulls), or + /// - an all-null chunk (null_count == num_values), which also sets column.is_all_null. + /// Only applies to flat, top-level primitive columns; see the implementation for the exact + /// conditions. void detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const; /// Returns mutable column because some of the recursive calls require it, diff --git a/tests/queries/0_stateless/04812_parquet_all_null_column_optimization.reference b/tests/queries/0_stateless/04812_parquet_all_null_column_optimization.reference new file mode 100644 index 000000000000..dfda8ff8759c --- /dev/null +++ b/tests/queries/0_stateless/04812_parquet_all_null_column_optimization.reference @@ -0,0 +1,14 @@ +-- all-null column, optimization on +1 1000 +-- all-null column, optimization off (must be identical) +1 1000 +-- the varying column is read correctly (not treated as constant) +499500 0 999 1000 +-- IS NULL / IS NOT NULL filters on an all-null column +1000 +0 +-- null_as_default: a non-nullable hint substitutes the default (0) for every row +0 1000 +-- optimization fired only when enabled +1 +1 diff --git a/tests/queries/0_stateless/04812_parquet_all_null_column_optimization.sh b/tests/queries/0_stateless/04812_parquet_all_null_column_optimization.sh new file mode 100755 index 000000000000..21522716651d --- /dev/null +++ b/tests/queries/0_stateless/04812_parquet_all_null_column_optimization.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Tags: no-fasttest + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +USER_FILES_PATH=$($CLICKHOUSE_CLIENT_BINARY --query "select _path,_file from file('nonexist.txt', 'CSV', 'val1 char')" 2>&1 | grep Exception | awk '{gsub("/nonexist.txt","",$9); print $9}') +WORKING_DIR="${USER_FILES_PATH}/${CLICKHOUSE_TEST_UNIQUE_NAME}" +mkdir -p "${WORKING_DIR}" +DATA_FILE="${WORKING_DIR}/all_null.parquet" + +# 1000 rows, 100 rows per row group => 10 row groups. `k` varies; `c_null` is NULL in every row, so +# each of its column chunks has null_count == num_values and no min/max value. The reader +# materializes such chunks directly from statistics without fetching any data pages. +${CLICKHOUSE_CLIENT} -q " + INSERT INTO FUNCTION file('${DATA_FILE}', Parquet) + SELECT + number AS k, + NULL::Nullable(Int64) AS c_null + FROM numbers(1000) + SETTINGS engine_file_truncate_on_insert = 1, output_format_parquet_row_group_size = 100 +" + +STRUCTURE="k UInt64, c_null Nullable(Int64)" + +qid_on="${CLICKHOUSE_TEST_UNIQUE_NAME}_on" +qid_off="${CLICKHOUSE_TEST_UNIQUE_NAME}_off" + +echo "-- all-null column, optimization on" +${CLICKHOUSE_CLIENT} --query_id="${qid_on}" -q " + SELECT c_null IS NULL AS is_null, count() + FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') + GROUP BY 1 +" + +echo "-- all-null column, optimization off (must be identical)" +${CLICKHOUSE_CLIENT} --query_id="${qid_off}" -q " + SELECT c_null IS NULL AS is_null, count() + FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') + GROUP BY 1 + SETTINGS input_format_parquet_use_constant_column_optimization = 0 +" + +echo "-- the varying column is read correctly (not treated as constant)" +${CLICKHOUSE_CLIENT} -q "SELECT sum(k), min(k), max(k), count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}')" + +echo "-- IS NULL / IS NOT NULL filters on an all-null column" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE c_null IS NULL" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE c_null IS NOT NULL" + +echo "-- null_as_default: a non-nullable hint substitutes the default (0) for every row" +${CLICKHOUSE_CLIENT} -q " + SELECT c_null, count() + FROM file('${DATA_FILE}', Parquet, 'k UInt64, c_null Int64') + GROUP BY 1 + SETTINGS input_format_null_as_default = 1 +" + +echo "-- optimization fired only when enabled" +${CLICKHOUSE_CLIENT} -q " + SYSTEM FLUSH LOGS query_log; + SELECT ProfileEvents['ParquetConstantColumnChunks'] > 0 + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_on}' AND type = 'QueryFinish' AND current_database = currentDatabase(); + SELECT ProfileEvents['ParquetConstantColumnChunks'] = 0 + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_off}' AND type = 'QueryFinish' AND current_database = currentDatabase(); +" + +rm -rf "${WORKING_DIR}" From 5fa11c00cd8d5a568781edc00eb1f5196e4add40 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 17:25:27 +0300 Subject: [PATCH 11/39] Parquet v3: purge bad-merge contamination from SettingsChangesHistory Commit c6b70b3155c fabricated two entire version blocks (26.8 and 26.7) in SettingsChangesHistory.cpp holding ~80 setting entries pulled in by a bad merge. 63 of them reference settings that are not registered on this branch and 10 duplicate entries recorded elsewhere, so applying `compatibility` to an older version threw `UNKNOWN_SETTING` (e.g. `s3_base`, `unique_key_probe_implementation`), failing 02324_compatibility_setting. The only entry that belongs to this parquet commit is `input_format_parquet_use_constant_column_optimization`. Remove both fabricated blocks and keep just that setting, moved into the pre-existing 26.6 block. The resulting history equals the commits parent plus that single line. Follow-up to 77c2c72 which removed only the first offending entry. CI: https://github.com/Altinity/ClickHouse/actions/runs/31105816361/job/92630866726 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Core/SettingsChangesHistory.cpp | 91 +---------------------------- 1 file changed, 1 insertion(+), 90 deletions(-) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index abd986fbc9f0..b309acf27479 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -39,98 +39,9 @@ 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.8", - { - {"max_insert_threads", 1, 0, "Changed the default from 1 (no parallel execution) to auto (0), which resolves to the number of CPU cores available to the server, reduced under memory pressure via `max_insert_threads_min_free_memory_per_thread`. This parallelizes `INSERT SELECT` by default. Set to 1 to restore the previous single-threaded behavior."}, - {"s3_base", "", "", "New setting to specify the base URL for resolving relative URLs in the s3 table function and the S3 table engine."}, - {"use_query_condition_cache_for_top_k", false, false, "New setting to gate the query condition cache for `ORDER BY ... LIMIT n` (TopK) reads; disabled by default."}, - {"use_projection_index_in_read_pools", false, false, "New setting to drop mark ranges fully filtered out by a projection index before read tasks are created in MergeTree read pools."}, - {"allow_distinct_partitions_independently", false, true, "New setting to enable independent per-partition evaluation of `DISTINCT` when the partition expression is a deterministic function of the `DISTINCT` columns."}, - {"force_distinct_partitions_independently", false, false, "New setting to force independent per-partition evaluation of `DISTINCT` even when the cost heuristic would skip it."}, - {"max_number_of_partitions_for_independent_distinct", 128, 128, "New setting: maximal number of partitions to apply independent per-partition `DISTINCT`."}, - {"allow_lossy_numeric_supertype", false, false, "New setting that lets if/multiIf/coalesce/ifNull/array/map resolve all-numeric branches with no lossless common type (e.g. Decimal + Float64) to a numeric supertype (Float64, with possible precision loss), so the result can be aggregated. Independent of use_variant_as_common_type: with it off such branches previously raised NO_COMMON_TYPE, with it on they became a Variant; either way they now resolve to Float64."}, - {"throw_on_hive_partitioning_resolution_failure", false, true, "New setting to fail the query when Hive-style partitioning detection for an object storage table cannot list the storage, instead of running without the Hive partition columns."}, - {"allow_experimental_json_ast_dialect", false, false, "New setting to enable the `clickhouse_json` value of the `dialect` setting, which interprets queries as JSON ASTs (the output of `parseQueryToJSON`) instead of SQL text."}, - {"analyzer_compatibility_apply_final_to_all_joined_tables", false, false, "New setting on master (default false = the fixed behavior). The behavior flip itself is recorded under 26.6, and the introduction for backports to older release branches (with default true) under 26.4."}, - {"enable_parallel_single_level_merge", false, true, "New setting to parallelize the final merge of the single-level aggregation hash tables by splitting the key space into disjoint hash partitions that the threads merge independently."}, - {"ai_function_text_default_credentials", "", "", "New setting"}, - {"ai_function_embedding_default_credentials", "", "", "New setting"}, - {"ai_function_allow_insecure_endpoint", true, false, "AI functions now reject insecure (http) endpoints to remote hosts by default."}, - {"ai_function_max_api_calls_per_query", 0, 1000, "Bound outbound AI function HTTP calls per query by default (previously 0 - unlimited)."}, - {"join_runtime_filter_min_probe_rows", 0, 1000, "New setting to control minimum probe side size for installing JOIN runtime filters. It wasn't limited before, so previous value is 0 meaning always install."}, - {"optimize_trivial_count_with_sparsity_filter", false, true, "Promote to BETA and enable by default: serve `SELECT count() FROM t WHERE ` from the persisted per-column `num_defaults` / `num_rows` counters when `` partitions rows into defaults vs non-defaults. Requires the MergeTree setting `compute_exact_num_defaults_for_sparse_columns` (also enabled by default now)."}, - {"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)."}, - {"input_format_arrow_use_native_reader", true, true, "Obsolete setting, the native ClickHouse reader is now always used for the `Arrow` and `ArrowStream` formats (the Apache Arrow library-based reader has been removed)."}, - {"output_format_arrow_use_native_writer", true, true, "Obsolete setting, the native ClickHouse writer is now always used for the `Arrow` and `ArrowStream` formats (the Apache Arrow library-based writer has been removed)."}, - {"distributed_cache_min_inflight_bytes_to_discard_connection_on_seek", 0, 4 * 1024 * 1024, "New setting to drop and reopen a distributed cache connection on a seek when too many in-flight bytes would otherwise be discarded. Defaults to 4 MiB; 0 restores the previous behavior (always reuse the connection via the read range id)."}, - {"input_format_parquet_spatial_filter_push_down", false, true, "New setting: skip GeoParquet row groups and pages based on spatial predicates and bounding box statistics"}, - {"use_text_index_negative_tokens_cache", false, true, "New setting to cache absent text index tokens and avoid repeated dictionary lookups."}, - {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, - {"input_format_parquet_prefetch_bandwidth_hide_seconds", 0., 0., "New setting: read back-pressure for the Parquet v3 reader; stop prefetching compressed data pages ahead of decoding once in-flight compressed bytes exceed this many seconds of measured throughput. 0 (default) disables it."}, - }); - addSettingsChanges(settings_changes_history, "26.7", - { - {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, - {"dictionary_lazy_load", "auto", "auto", "New setting overriding the server setting `dictionaries_lazy_load` for an individual dictionary."}, - {"discard_query_data", false, false, "New setting to skip sending query result rows to the client over the native TCP protocol."}, - {"optimize_trivial_count_with_sparsity_filter", false, false, "New (experimental) setting to serve `SELECT count() FROM t WHERE ` from per-column `num_defaults` / `num_rows` recorded in `serialization.json` when `` partitions rows into defaults vs non-defaults."}, - {"merge_tree_generic_exclusion_search_max_steps", 0, 0, "New setting to limit the number of steps of the generic exclusion search over the primary key index."}, - {"use_streaming_marks_compression", false, false, "New setting to compress marks into in-memory representation one block at a time (streaming) instead of materializing the full plain marks array, reducing peak memory during marks loading for compact parts with many substreams."}, - {"s3_validate_etag_on_read", false, true, "New setting to detect concurrent in-place overwrites of S3/GCS objects during a read by validating the GET response ETag against the listed one. previous_value=false so `compatibility` with versions before 26.7 restores the pre-existing behavior (no validation)."}, - {"dead_blobs_to_delay_insert", 0, 0, "New setting to override the `MergeTree` setting with the same name per query."}, - {"dead_blobs_to_throw_insert", 0, 0, "New setting to override the `MergeTree` setting with the same name per query."}, - {"input_format_csv_missing_nullable_as_empty_string", false, false, "New setting to read a missing value of `Nullable(String)` from CSV as an empty string instead of NULL."}, - {"use_legacy_to_time", true, false, "Use the new `toTime` function (converting values to the `Time` data type) by default instead of the legacy `toTime` (which is still available as `toTimeWithFixedDate`)."}, - {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, - {"parallel_replicas_plan_based", false, false, "New setting"}, - {"use_paimon_metadata_files_cache", false, false, "New setting to enable in-memory caching of parsed Paimon metadata files (manifest lists and manifests). For persistent Paimon table engines it must be enabled before metadata initialization; table functions evaluate it per query. Avoids repeated downloads and deserialization of metadata files from object storage on subsequent queries."}, - {"optimize_or_like_chain", false, true, "Enable by default: optimize OR chains of LIKE/ILIKE/match into multiSearchAny (pure-substring patterns) or multiMatchAny (other patterns, when Hyperscan/Vectorscan is permitted); when neither fast path applies the original OR chain is kept unchanged."}, - {"optimize_or_like_chain_min_patterns", 0, 10, "New setting controlling the minimum number of non-pure-substring LIKE/ILIKE/match branches (sharing the same LHS expression) required for optimize_or_like_chain to rewrite a chain into multiMatchAny. Shorter chains are kept as-is because the multiMatchAny (Hyperscan) rewrite only becomes faster than short-circuit OR evaluation from about nine branches."}, - {"optimize_or_like_chain_min_substrings", 0, 4, "New setting controlling the minimum number of pure-substring (%needle%) LIKE/ILIKE branches (sharing the same LHS expression) required for optimize_or_like_chain to rewrite a chain into multiSearchAny."}, - {"input_format_arrow_use_native_reader", false, true, "New setting to use the native ClickHouse reader for the Arrow and ArrowStream formats instead of the Apache Arrow library."}, - {"input_format_orc_use_fast_decoder", true, true, "Obsolete setting, the native ClickHouse ORC decoder is now always used (the Apache Arrow-based ORC reader has been removed)."}, - {"output_format_arrow_use_native_writer", false, true, "New setting to use the native ClickHouse writer for the Arrow and ArrowStream formats instead of the Apache Arrow library."}, - {"allow_minmax_index_for_json", true, false, "Forbid creating minmax skip index on JSON columns by default because the index serialization cannot handle heterogeneous Field values"}, - {"s3_allow_server_credentials_in_user_queries", true, false, "New setting to block S3 access from user SQL from resolving the server's own ambient credentials (environment/IMDS/IRSA/instance-profile/AWS-config-file/GCP-OAuth-metadata). Explicit role_arn-based STS assume-role is still allowed. The previous behavior (allowed) is restored with compatibility settings."}, - {"query_plan_merge_expression_into_join", false, true, "New setting. Allow to merge Expression step into JOIN step during join reordering optimization."}, - {"skip_unavailable_shards_mode", "unavailable_or_table_missing", "unavailable_or_table_missing", "New setting to control which exceptions from a remote shard are ignored when `skip_unavailable_shards` is enabled. The default matches the historical behavior: a shard whose table is missing is treated as unavailable."}, - {"use_text_index_tokens_cache", false, true, "Enabled the text index tokens cache globally."}, - {"use_text_index_header_cache", false, true, "Enabled the text index header cache globally."}, - {"optimize_aggregation_in_order_limit", false, true, "New setting to push the `LIMIT` into aggregation-in-order for early termination when the `ORDER BY` is a prefix of the `GROUP BY` sort description."}, - {"explain_query_plan_default", "legacy", "pretty", "From 26.7, `EXPLAIN PLAN` defaults to `actions=1, compact=1, pretty=1`. Set this to `legacy` to restore the pre-26.7 output."}, - {"format_geojson_validate_geometry", true, true, "New setting that controls whether the GeoJSON format enforces RFC 7946 geometry validity (minimum points per line and ring, ring closure, non-empty multi-geometries) when reading and writing"}, - {"use_partition_minmax_for_primary_key_pruning", false, true, "New setting to use the part's partition minmax to prune more granules during primary key analysis for `MergeTree` tables, when a primary key column is also an input column of the partition key."}, - {"allow_delta_lake_writes", false, false, "Added an alias for setting `allow_experimental_delta_lake_writes`, which was moved to Beta."}, - {"allow_experimental_delta_lake_writes", false, false, "Delta Lake writes were moved to Beta."}, - {"optimize_redundant_comparisons", false, true, "New setting to detect conflicting and redundant comparison conditions on the same expression within AND chains."}, - {"mysql_datatypes_support_level", "decimal,datetime64,date2Date32", "decimal,datetime64,date2Date32,geometry", "Map MySQL's concrete spatial types (LINESTRING, POLYGON, MULTILINESTRING, MULTIPOLYGON, MULTIPOINT) and the generic GEOMETRY type to the corresponding ClickHouse geometric types by default. The generic GEOMETRY column maps to the umbrella Geometry type; reading a value whose subtype has no ClickHouse counterpart (GEOMETRYCOLLECTION) throws at read time."}, - {"snappy_mode", "basic", "basic", "New setting to control the wire format used for snappy compression in generic file/URL I/O. The default `basic` preserves backward-compatible Hadoop snappy block format reads; HTTP `Content-Encoding: snappy` always uses the framing format independently of this setting."}, - {"compile_regular_expressions", false, true, "New setting to enable JIT compilation of simple regular expressions in functions like `match` and `extract`."}, - {"min_count_to_compile_regular_expression", 3, 3, "New setting controlling how many times a regular expression must be used before it is JIT-compiled."}, - {"allow_aggregate_partitions_independently", false, true, "Enable independent per-partition aggregation by default when the partition key suits the GROUP BY key. The existing runtime heuristics in `ReadFromMergeTree::requestOutputEachPartitionThroughSeparatePortForAggregation` already skip the optimization when the partition layout is unfavorable (too few partitions, too many partitions, or significantly skewed partition sizes), so enabling the setting is safe in the cases where it would otherwise be a no-op."}, - {"text_index_lazy_intersection_density_threshold", 0.2, 0.2, "Renamed from `text_index_density_threshold` (kept as an alias); selects the posting list intersection algorithm in lazy posting list apply mode."}, - {"allow_experimental_text_index_lazy_apply", false, true, "Lazy posting list apply mode for the text index is no longer experimental; the setting is now obsolete and has no effect (lazy mode is selected via `text_index_posting_list_apply_mode = 'lazy'`)."}, - {"allow_experimental_url_wildcard_from_index_pages", false, false, "New setting to enable expanding wildcards in the `url` table function by listing HTTP index pages."}, - {"url_wildcard_max_directories_to_read", 100000, 100000, "New setting to limit the number of directories read when expanding wildcards in the `url` table function."}, - {"allow_experimental_eval_table_function", false, false, "New setting to enable the experimental table function `eval`."}, - {"output_format_csv_header_serialize_tuple_into_separate_columns", false, true, "New setting. When output_format_csv_serialize_tuple_into_separate_columns is enabled, the CSVWithNames/CSVWithNamesAndTypes header now flattens Tuple columns into their leaf fields so the header width matches the data. Set to false to restore the previous single-name header."}, - {"enable_join_runtime_filters_index_analysis", false, false, "New setting to enable join filtering using dynamic index analysis"}, - {"vector_search_use_quantized_codes", false, false, "New setting to opt into the two-stage approximate vector-search optimization over a Quantize(...) column codec; queries stay exact by default."}, - {"reader_executor_use_long_connections", false, false, "New experimental ReaderExecutor setting (off by default): reuse a held source connection across sequential windows."}, - {"reader_executor_min_bytes_for_seek", 2097152, 2097152, "New experimental ReaderExecutor setting: forward-gap bound for bridging on a held source connection."}, - {"reader_executor_max_tail_for_drain", 1048576, 1048576, "New experimental ReaderExecutor setting: drain bound for completing a dropped long connection."}, - {"precise_float_parsing", false, true, "Use the precise (closest-representable) float parsing algorithm by default, now that it is faster than the previous fast algorithm. Set to false to restore the pre-26.7 fast-but-less-accurate parsing in conversion functions."}, - {"optimize_and_compare_chain_max_hash_work", 0, 5'000'000, "New setting that bounds the work of the `optimize_and_compare_chain` optimization (measured in query-tree nodes hashed) so it cannot dominate analysis of queries with very many or very large `AND`-chains of comparisons. The previous value `0` (unlimited) reproduces the pre-26.7 behavior where the optimization was uncapped, so `compatibility` set to an earlier version keeps deriving transitive predicates without a budget. Set to `0` to disable the budget."}, - {"iceberg_manifest_min_count_to_compact", 30, 30, "New setting to control manifest compaction for Iceberg tables."}, - {"show_remote_databases_in_system_tables", true, true, "New setting to control whether `MySQL` and `PostgreSQL` databases are shown in `system.tables`, `system.columns` and `system.completions`."}, - {"use_constant_folding_in_index_analysis", false, false, "New setting to fold partition-level constants into the filter predicate per part during MergeTree index analysis, improving pruning for filters whose branches depend on partition values."}, - {"join_runtime_filter_size_from_hash_table_stats", false, true, "Use hash table size statistics collected from previous executions to size the JOIN runtime filter. When disabled, fall back to the fixed `join_runtime_bloom_filter_bytes`."}, - }); - addSettingsChanges(settings_changes_history, "26.6", { + {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, {"output_format_image_width", 1024, 1024, "New setting controlling the width of the output image for image output formats such as PNG."}, From 2175750d40ccc0daad9c912d50375aa9f2404d15 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 18:30:04 +0300 Subject: [PATCH 12/39] Parquet v3: record input_format_parquet_prefetch_bandwidth_hide_seconds in history The setting was added by a019cd70c01 but never given a SettingsChangesHistory entry, so 02995_new_settings_history reported it as an undocumented new setting (the failure surfaced once 02324_compatibility_setting stopped failing first). Add it to the 26.6 block next to input_format_parquet_use_constant_column_optimization; default 0 disables the back-pressure and matches the pre-existing behavior. Reproduced the full 02995 check locally (registered settings minus both baseline TSVs minus recent-version history): this was the only missing setting. CI: https://github.com/Altinity/ClickHouse/actions/runs/31110878761/job/92648476492 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Core/SettingsChangesHistory.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index b309acf27479..9dbeae45e4fa 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -42,6 +42,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() addSettingsChanges(settings_changes_history, "26.6", { {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, + {"input_format_parquet_prefetch_bandwidth_hide_seconds", 0, 0, "New setting: read back-pressure for the Parquet v3 reader; stop prefetching compressed data pages once in-flight compressed bytes exceed this many seconds of measured read throughput. 0 (the default and the pre-existing behavior) disables the back-pressure."}, {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, {"output_format_image_width", 1024, 1024, "New setting controlling the width of the output image for image output formats such as PNG."}, From bb601c15f194ebb6d69fa7708573c4106cc6cdf4 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 6 Aug 2026 18:56:13 +0300 Subject: [PATCH 13/39] Re-trigger CI after GitHub Actions infra outage The previous run failed in Config Workflow / Set up job with "Failed to resolve action download info. Error: Service Unavailable" - a transient GitHub Actions outage, not a code failure. Empty commit to re-run CI. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus From c89ccf1722c3a3d2a44f4684674104aa17cc31c1 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 12:55:29 +0300 Subject: [PATCH 14/39] Port DataLakeCatalog namespace filter (Altinity/ClickHouse#1337) Adds the DataLakeCatalog `namespaces` setting: a comma-separated list of allowed namespaces for `rest`, `glue` and `unity` catalog types, so a DataLake database exposes only the selected namespaces. `rest` supports nested rules (`foo`, `foo.bar`, `foo.*`) via `RestCatalog::AllowedNamespaces`; `glue`/`unity` use a flat allow-set. Default `*` allows everything (pre-existing behavior). Ported from the merged antalya-25.8 PR onto antalya-26.6. Cross-version adaptations: - Catalog construction moved into `DatabaseDataLake` in 26.6, so the namespaces are threaded through `CatalogSettings` / the catalog constructors there; the 25.8 inline construction in `DataLakeConfiguration::getCatalog` (and its `catalog_namespaces` plumbing) is obsolete and dropped, leaving `DataLakeConfiguration.h` unchanged. - `CATALOG_NAMESPACE_DISABLED` error code renumbered 757 -> 779 (757..778 are already taken on this branch). - The namespace filter checks were merged into 26.6-refactored code paths (threadpool-based `RestCatalog::getTables`, extracted Glue credentials provider, 26.6 `resolveMetadataPathFromTableLocation`). - The delegating 6-arg `RestCatalog` constructor (used by OneLake/BigLake, which do not support the filter) defaults `allowed_namespaces` to `*`. - Added explicit `` and ``/ `` includes (the upstream PR relied on transitive includes). PR: https://github.com/Altinity/ClickHouse/pull/1337 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- docs/en/engines/database-engines/datalake.md | 29 ++++- src/Common/ErrorCodes.cpp | 1 + src/Databases/DataLake/DatabaseDataLake.cpp | 4 + .../DataLake/DatabaseDataLakeSettings.cpp | 1 + src/Databases/DataLake/GlueCatalog.cpp | 29 ++++- src/Databases/DataLake/GlueCatalog.h | 4 + src/Databases/DataLake/ICatalog.h | 1 + src/Databases/DataLake/RestCatalog.cpp | 109 +++++++++++++++++- src/Databases/DataLake/RestCatalog.h | 22 ++++ src/Databases/DataLake/UnityCatalog.cpp | 17 ++- src/Databases/DataLake/UnityCatalog.h | 6 + .../gtest_rest_catalog_allowed_namespaces.cpp | 69 +++++++++++ tests/integration/test_database_delta/test.py | 42 +++++++ tests/integration/test_database_glue/test.py | 40 +++++++ .../integration/test_database_iceberg/test.py | 67 +++++++++++ 15 files changed, 436 insertions(+), 5 deletions(-) create mode 100644 src/Databases/DataLake/tests/gtest_rest_catalog_allowed_namespaces.cpp diff --git a/docs/en/engines/database-engines/datalake.md b/docs/en/engines/database-engines/datalake.md index b37fc38f790d..45ba8f070f37 100644 --- a/docs/en/engines/database-engines/datalake.md +++ b/docs/en/engines/database-engines/datalake.md @@ -59,6 +59,7 @@ The following settings are supported: | `region` | AWS region for the service (e.g., `us-east-1`) | | `dlf_access_key_id` | Access key ID for DLF access | | `dlf_access_key_secret` | Access key Secret for DLF access | +| `namespaces` | Comma-separated list of namespaces, implemented for catalog types: `rest`, `glue` and `unity` | ## Examples {#examples} @@ -81,4 +82,30 @@ SETTINGS onelake_client_secret = client_secret; SHOW TABLES IN database_name; SELECT count() from database_name.table_name; -``` \ No newline at end of file +``` + +## Namespace filter {#namespace} + +By default, ClickHouse reads tables from all namespaces available in the catalog. You can limit this behavior using the `namespaces` database setting. The value should be a comma‑separated list of namespaces that are allowed to be read. + +Supported catalog types are `rest`, `glue` and `unity`. + +For example, if the catalog contains three namespaces - `dev`, `stage`, and `prod` - and you want to read data only from dev and stage, set: +``` +namespaces='dev,stage' +``` + +### Nested namespaces {#namespace-nested} + +The Iceberg (`rest`) catalog supports nested namespaces. The `namespaces` filter accepts the following patterns: + +- `namespace` - includes tables from the specified namespace, but not from its nested namespaces. +- `namespace.nested` - includes tables from the nested namespace, but not from the parent. +- `namespace.*` - includes tables from all nested namespaces, but not from the parent. + +If you need to include both a namespace and its nested namespaces, specify both explicitly. For example: +``` +namespaces='namespace,namespace.*' +``` + +The default value is '*', which means all namespaces are included. diff --git a/src/Common/ErrorCodes.cpp b/src/Common/ErrorCodes.cpp index 07ec3fff851f..ad3113b17b1f 100644 --- a/src/Common/ErrorCodes.cpp +++ b/src/Common/ErrorCodes.cpp @@ -658,6 +658,7 @@ M(776, RESOURCE_LIMIT_EXCEEDED) \ M(777, MEMORY_RESERVATION_KILLED) \ M(778, MEMORY_RESERVATION_FAILED) \ + M(779, CATALOG_NAMESPACE_DISABLED) \ \ M(900, DISTRIBUTED_CACHE_ERROR) \ M(901, CANNOT_USE_DISTRIBUTED_CACHE) \ diff --git a/src/Databases/DataLake/DatabaseDataLake.cpp b/src/Databases/DataLake/DatabaseDataLake.cpp index 12fbb051ba4a..a2e737182669 100644 --- a/src/Databases/DataLake/DatabaseDataLake.cpp +++ b/src/Databases/DataLake/DatabaseDataLake.cpp @@ -66,6 +66,7 @@ namespace DatabaseDataLakeSetting extern const DatabaseDataLakeSettingsString aws_access_key_id; extern const DatabaseDataLakeSettingsString aws_secret_access_key; extern const DatabaseDataLakeSettingsString region; + extern const DatabaseDataLakeSettingsString namespaces; extern const DatabaseDataLakeSettingsString aws_role_arn; extern const DatabaseDataLakeSettingsString aws_role_session_name; extern const DatabaseDataLakeSettingsString aws_external_id; @@ -178,6 +179,7 @@ void DatabaseDataLake::initialize() const .aws_access_key_id = settings[DatabaseDataLakeSetting::aws_access_key_id].value, .aws_secret_access_key = settings[DatabaseDataLakeSetting::aws_secret_access_key].value, .region = settings[DatabaseDataLakeSetting::region].value, + .namespaces = settings[DatabaseDataLakeSetting::namespaces].value, .aws_role_arn = settings[DatabaseDataLakeSetting::aws_role_arn].value, .aws_role_session_name = settings[DatabaseDataLakeSetting::aws_role_session_name].value, .aws_external_id = settings[DatabaseDataLakeSetting::aws_external_id].value, @@ -195,6 +197,7 @@ void DatabaseDataLake::initialize() const settings[DatabaseDataLakeSetting::auth_header], settings[DatabaseDataLakeSetting::oauth_server_uri].value, settings[DatabaseDataLakeSetting::oauth_server_use_request_body].value, + settings[DatabaseDataLakeSetting::namespaces].value, Context::getGlobalContextInstance()); break; } @@ -248,6 +251,7 @@ void DatabaseDataLake::initialize() const settings[DatabaseDataLakeSetting::warehouse].value, url, settings[DatabaseDataLakeSetting::catalog_credential].value, + settings[DatabaseDataLakeSetting::namespaces].value, Context::getGlobalContextInstance()); break; } diff --git a/src/Databases/DataLake/DatabaseDataLakeSettings.cpp b/src/Databases/DataLake/DatabaseDataLakeSettings.cpp index 969b0769d13a..a982ea0a7663 100644 --- a/src/Databases/DataLake/DatabaseDataLakeSettings.cpp +++ b/src/Databases/DataLake/DatabaseDataLakeSettings.cpp @@ -48,6 +48,7 @@ namespace ErrorCodes DECLARE(String, dlf_access_key_id, "", "Access id of DLF token for Paimon REST Catalog", 0) \ DECLARE(String, dlf_access_key_secret, "", "Access secret of DLF token for Paimon REST Catalog", 0) \ DECLARE(Bool, force_add_bucket, false, "Add bucket name to the metadata path", 0) \ + DECLARE(String, namespaces, "*", "Comma-separated list of allowed namespaces", 0) \ #define LIST_OF_DATABASE_ICEBERG_SETTINGS(M, ALIAS) \ DATABASE_ICEBERG_RELATED_SETTINGS(M, ALIAS) \ diff --git a/src/Databases/DataLake/GlueCatalog.cpp b/src/Databases/DataLake/GlueCatalog.cpp index 53ac171c79ff..67878334bacd 100644 --- a/src/Databases/DataLake/GlueCatalog.cpp +++ b/src/Databases/DataLake/GlueCatalog.cpp @@ -54,11 +54,14 @@ #include #include +#include + namespace DB::ErrorCodes { extern const int BAD_ARGUMENTS; extern const int DATALAKE_DATABASE_ERROR; extern const int FAULT_INJECTED; + extern const int CATALOG_NAMESPACE_DISABLED; } namespace DB::FailPoints @@ -172,6 +175,8 @@ GlueCatalog::GlueCatalog( LOG_TRACE(log, "Creating AWS glue client with credentials empty {}, region '{}', endpoint '{}'", credentials.IsEmpty(), region, endpoint); } + boost::split(allowed_namespaces, settings.namespaces, boost::is_any_of(", "), boost::token_compress_on); + credentials_provider = DB::S3::getCredentialsProvider(poco_config, credentials, creds_config); glue_client = std::make_unique(credentials_provider, endpoint_provider, client_configuration); @@ -200,8 +205,9 @@ DataLake::ICatalog::Namespaces GlueCatalog::getDatabases(const std::string & pre for (const auto & db : dbs) { const auto & db_name = db.GetName(); - if (!db_name.starts_with(prefix)) + if (!isNamespaceAllowed(db_name) || !db_name.starts_with(prefix)) continue; + result.push_back(db_name); if (limit != 0 && result.size() >= limit) break; @@ -286,6 +292,9 @@ DB::Names GlueCatalog::getTables() const bool GlueCatalog::existsTable(const std::string & database_name, const std::string & table_name) const { + if (!isNamespaceAllowed(database_name)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, "Namespace {} is filtered by `namespaces` database parameter", database_name); + Aws::Glue::Model::GetTableRequest request; request.SetDatabaseName(database_name); request.SetName(table_name); @@ -299,6 +308,9 @@ bool GlueCatalog::tryGetTableMetadata( const std::string & table_name, TableMetadata & result) const { + if (!isNamespaceAllowed(database_name)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, "Namespace {} is filtered by `namespaces` database parameter", database_name); + Aws::Glue::Model::GetTableRequest request; request.SetDatabaseName(database_name); request.SetName(table_name); @@ -604,6 +616,11 @@ void GlueCatalog::createNamespaceIfNotExists(const String & namespace_name) cons void GlueCatalog::createTable(const String & namespace_name, const String & table_name, const String & new_metadata_path, Poco::JSON::Object::Ptr /*metadata_content*/) const { + if (!isNamespaceAllowed(namespace_name)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, + "Failed to create table {}, namespace {} is filtered by `namespaces` database parameter", + table_name, namespace_name); + createNamespaceIfNotExists(namespace_name); Aws::Glue::Model::CreateTableRequest request; @@ -686,6 +703,11 @@ bool GlueCatalog::updateSchema( void GlueCatalog::dropTable(const String & namespace_name, const String & table_name) const { + if (!isNamespaceAllowed(namespace_name)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, + "Failed to drop table {}, namespace {} is filtered by `namespaces` database parameter", + table_name, namespace_name); + Aws::Glue::Model::DeleteTableRequest request; request.SetDatabaseName(namespace_name); request.SetName(table_name); @@ -699,6 +721,11 @@ void GlueCatalog::dropTable(const String & namespace_name, const String & table_ response.GetError().GetMessage()); } +bool GlueCatalog::isNamespaceAllowed(const std::string & namespace_) const +{ + return allowed_namespaces.contains("*") || allowed_namespaces.contains(namespace_); +} + } #endif diff --git a/src/Databases/DataLake/GlueCatalog.h b/src/Databases/DataLake/GlueCatalog.h index 919b13a5669f..396b7be488bb 100644 --- a/src/Databases/DataLake/GlueCatalog.h +++ b/src/Databases/DataLake/GlueCatalog.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -100,6 +101,9 @@ class GlueCatalog final : public ICatalog, private DB::WithContext std::string region; CatalogSettings settings; DB::ASTPtr table_engine_definition; + std::unordered_set allowed_namespaces; + + bool isNamespaceAllowed(const std::string & namespace_) const; DataLake::ICatalog::Namespaces getDatabases(const std::string & prefix, size_t limit = 0) const; DB::Names getTablesForDatabase(const std::string & db_name, size_t limit = 0) const; diff --git a/src/Databases/DataLake/ICatalog.h b/src/Databases/DataLake/ICatalog.h index e14b00ac3732..357f6cd14daf 100644 --- a/src/Databases/DataLake/ICatalog.h +++ b/src/Databases/DataLake/ICatalog.h @@ -128,6 +128,7 @@ struct CatalogSettings String aws_access_key_id; String aws_secret_access_key; String region; + String namespaces; String aws_role_arn; String aws_role_session_name; String aws_external_id; diff --git a/src/Databases/DataLake/RestCatalog.cpp b/src/Databases/DataLake/RestCatalog.cpp index 9f85e9d80d6d..72f44ad54f96 100644 --- a/src/Databases/DataLake/RestCatalog.cpp +++ b/src/Databases/DataLake/RestCatalog.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -56,6 +57,7 @@ namespace DB::ErrorCodes extern const int LOGICAL_ERROR; extern const int BAD_ARGUMENTS; extern const int FAULT_INJECTED; + extern const int CATALOG_NAMESPACE_DISABLED; } namespace DB::Setting @@ -170,6 +172,7 @@ RestCatalog::RestCatalog( const std::string & auth_header_, const std::string & oauth_server_uri_, bool oauth_server_use_request_body_, + const std::string & namespaces_, DB::ContextPtr context_) : ICatalog(warehouse_) , DB::WithContext(context_) @@ -178,6 +181,7 @@ RestCatalog::RestCatalog( , auth_scope(auth_scope_) , oauth_server_uri(oauth_server_uri_) , oauth_server_use_request_body(oauth_server_use_request_body_) + , allowed_namespaces(namespaces_) { if (!catalog_credential_.empty()) { @@ -213,6 +217,7 @@ RestCatalog::RestCatalog( , auth_scope(auth_scope_) , oauth_server_uri(oauth_server_uri_) , oauth_server_use_request_body(oauth_server_use_request_body_) + , allowed_namespaces("*") { } @@ -643,6 +648,9 @@ bool RestCatalog::empty() const if (found_table) return true; + if (!allowed_namespaces.isNamespaceAllowed(namespace_name, /*nested*/ false)) + return false; + const auto tables = getTables(namespace_name, /* limit */1); if (!tables.empty()) found_table = true; @@ -668,6 +676,8 @@ DB::Names RestCatalog::getTables() const auto execute_for_each_namespace = [&](const std::string & current_namespace) { + if (!allowed_namespaces.isNamespaceAllowed(current_namespace, /*nested*/ false)) + return; runner.enqueueAndKeepTrack( [=, &tables, &mutex, this] { @@ -717,9 +727,21 @@ void RestCatalog::getNamespacesRecursive( break; if (func) - func(current_namespace); + { + if (allowed_namespaces.isNamespaceAllowed(current_namespace, /*nested*/ false)) + func(current_namespace); + else + { + LOG_DEBUG(log, "Tables in namespace {} are filtered", current_namespace); + } + } - getNamespacesRecursive(current_namespace, result, stop_condition, func); + if (allowed_namespaces.isNamespaceAllowed(current_namespace, /*nested*/ true)) + getNamespacesRecursive(current_namespace, result, stop_condition, func); + else + { + LOG_DEBUG(log, "Nested namespaces in namespace {} are filtered", current_namespace); + } } } @@ -894,6 +916,10 @@ RestCatalog::Namespaces RestCatalog::parseNamespaces(DB::ReadBuffer & buf, const DB::Names RestCatalog::getTables(const std::string & base_namespace, size_t limit) const { + if (!allowed_namespaces.isNamespaceAllowed(base_namespace, /*nested*/ false)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, + "Namespace {} is filtered by `namespaces` database parameter", base_namespace); + auto encoded_namespace = encodeNamespaceForURI(base_namespace); const std::string endpoint = std::filesystem::path(NAMESPACES_ENDPOINT) / encoded_namespace / "tables"; @@ -1017,6 +1043,8 @@ bool RestCatalog::tryGetTableMetadata( } catch (const DB::Exception & ex) { + if (ex.code() == DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED) + throw; LOG_DEBUG(log, "tryGetTableMetadata response: {}", ex.what()); return false; } @@ -1038,6 +1066,10 @@ bool RestCatalog::getTableMetadataImpl( { LOG_DEBUG(log, "Checking table {} in namespace {}", table_name, namespace_name); + if (!allowed_namespaces.isNamespaceAllowed(namespace_name, /*nested*/ false)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, + "Namespace {} is filtered by `namespaces` database parameter", namespace_name); + DB::HTTPHeaderEntries headers; if (result.requiresCredentials()) { @@ -1197,6 +1229,10 @@ void RestCatalog::createNamespaceIfNotExists(const String & namespace_name, cons void RestCatalog::createTable(const String & namespace_name, const String & table_name, const String & /*new_metadata_path*/, Poco::JSON::Object::Ptr metadata_content) const { + if (!allowed_namespaces.isNamespaceAllowed(namespace_name, /*nested*/ false)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, + "Failed to create table {}, namespace {} is filtered by `namespaces` database parameter", table_name, namespace_name); + createNamespaceIfNotExists(namespace_name, metadata_content->getValue("location")); const std::string endpoint = (base_url / config.prefix / NAMESPACES_ENDPOINT / encodeNamespaceForURI(namespace_name) / "tables").generic_string(); @@ -1368,6 +1404,11 @@ bool RestCatalog::updateSchema( void RestCatalog::dropTable(const String & namespace_name, const String & table_name) const { + if (!allowed_namespaces.isNamespaceAllowed(namespace_name, /*nested*/ false)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, + "Failed to drop table {}, namespace {} is filtered by `namespaces` database parameter", + table_name, namespace_name); + const std::string endpoint = fmt::format("{}/namespaces/{}/tables/{}?purgeRequested=False", base_url, namespace_name, table_name); Poco::JSON::Object::Ptr request_body = nullptr; @@ -1504,6 +1545,70 @@ ICatalog::CredentialsRefreshCallback RestCatalog::getCredentialsConfigurationCal }; } +/// "alpha,alpha.a1,bravo,bravo.*,charlie,delta.d1,echo.*" +/// allows tables from +/// - "alpha" namespace +/// - "alpha.a1" namespace +/// - "bravo" namespace +/// - any nested namespaces of "bravo" +/// - "charlie" namespace, but not from nested of "charlie" +/// - "delta.d1" namespace, but not from "delta" +/// - any nested namespaces of "echo", but not "echo" itself +/// "bravo.*.b2" makes no sense for now, asterisk allows all nested +RestCatalog::AllowedNamespaces::AllowedNamespaces(const std::string & namespaces_) +{ + std::vector list_of_namespaces; + boost::split(list_of_namespaces, namespaces_, boost::is_any_of(", "), boost::token_compress_on); + for (const auto & ns : list_of_namespaces) + { + std::vector list_of_nested_namespaces; + boost::split(list_of_nested_namespaces, ns, boost::is_any_of(".")); + + size_t len = list_of_nested_namespaces.size(); + if (!len) + continue; + + AllowedNamespaces * current = &(nested_namespaces[list_of_nested_namespaces[0]]); + for (size_t i = 1; i <= len; ++i) + { + if (i == len) + current->allow_tables = true; + else + { + current = &(current->nested_namespaces[list_of_nested_namespaces[i]]); + if (list_of_nested_namespaces[i] == "*") + { + current->allow_tables = true; + break; + } + } + } + } +} + +bool RestCatalog::AllowedNamespaces::isNamespaceAllowed(const std::string & namespace_, bool nested) const +{ + // Trivial case, check here to avoid split namespace on nested + if (nested_namespaces.contains("*")) + return true; + + std::vector list_of_nested_namespaces; + boost::split(list_of_nested_namespaces, namespace_, boost::is_any_of(".")); + + const AllowedNamespaces * current = this; + for (const auto & nns : list_of_nested_namespaces) + { + if (current->nested_namespaces.contains("*")) + return true; + auto it = current->nested_namespaces.find(nns); + if (it == current->nested_namespaces.end()) + return false; + current = &(it->second); + } + + return nested ? !current->nested_namespaces.empty() : current->allow_tables; +} + } #endif diff --git a/src/Databases/DataLake/RestCatalog.h b/src/Databases/DataLake/RestCatalog.h index 982475ee2c96..aa90d9bbdbf4 100644 --- a/src/Databases/DataLake/RestCatalog.h +++ b/src/Databases/DataLake/RestCatalog.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,7 @@ class RestCatalog : public ICatalog, public DB::WithContext const std::string & auth_header_, const std::string & oauth_server_uri_, bool oauth_server_use_request_body_, + const std::string & namespaces_, DB::ContextPtr context_); ~RestCatalog() override = default; @@ -131,6 +133,26 @@ class RestCatalog : public ICatalog, public DB::WithContext bool oauth_server_use_request_body; mutable MultiVersion access_token; +public: + class AllowedNamespaces + { + public: + AllowedNamespaces() {} + explicit AllowedNamespaces(const std::string & namespaces_); + + /// Check if nested namespaces (nesetd=true) or tables (nested=false) are allowed in namespace + bool isNamespaceAllowed(const std::string & namespace_, bool nested) const; + + private: + /// List of allowed nested namespaces + std::unordered_map nested_namespaces; + /// Tables from current level are allowed + bool allow_tables = false; + }; + +private: + AllowedNamespaces allowed_namespaces; + Poco::Net::HTTPBasicCredentials credentials{}; DB::ReadWriteBufferFromHTTPPtr createReadBuffer( diff --git a/src/Databases/DataLake/UnityCatalog.cpp b/src/Databases/DataLake/UnityCatalog.cpp index 414b7e439ecf..c81f03d78d6f 100644 --- a/src/Databases/DataLake/UnityCatalog.cpp +++ b/src/Databases/DataLake/UnityCatalog.cpp @@ -14,12 +14,14 @@ #include #include #include +#include namespace DB::ErrorCodes { extern const int DATALAKE_DATABASE_ERROR; extern const int LOGICAL_ERROR; extern const int BAD_ARGUMENTS; + extern const int CATALOG_NAMESPACE_DISABLED; } namespace @@ -162,6 +164,9 @@ bool UnityCatalog::tryGetTableMetadata( const std::string & table_name, TableMetadata & result) const { + if (!isNamespaceAllowed(schema_name)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, "Namespace {} is filtered by `namespaces` database parameter", schema_name); + auto full_table_name = warehouse + "." + schema_name + "." + table_name; Poco::Dynamic::Var json; std::string json_str; @@ -284,6 +289,9 @@ bool UnityCatalog::tryGetTableMetadata( bool UnityCatalog::existsTable(const std::string & schema_name, const std::string & table_name) const { + if (!isNamespaceAllowed(schema_name)) + throw DB::Exception(DB::ErrorCodes::CATALOG_NAMESPACE_DISABLED, "Namespace {} is filtered by `namespaces` database parameter", schema_name); + String json_str; Poco::Dynamic::Var json; try @@ -393,7 +401,7 @@ DataLake::ICatalog::Namespaces UnityCatalog::getSchemas(const std::string & base chassert(schema_info->get("catalog_name").extract() == warehouse); UnityCatalogFullSchemaName schema_name = parseFullSchemaName(schema_info->get("full_name").extract()); - if (schema_name.schema_name.starts_with(base_prefix)) + if (isNamespaceAllowed(schema_name.schema_name) && schema_name.schema_name.starts_with(base_prefix)) schemas.push_back(schema_name.schema_name); if (limit && schemas.size() > limit) @@ -435,6 +443,7 @@ UnityCatalog::UnityCatalog( const std::string & catalog_, const std::string & base_url_, const std::string & catalog_credential_, + const std::string & namespaces_, DB::ContextPtr context_) : ICatalog(catalog_) , DB::WithContext(context_) @@ -442,6 +451,12 @@ UnityCatalog::UnityCatalog( , log(getLogger("UnityCatalog(" + catalog_ + ")")) , auth_header("Authorization", "Bearer " + catalog_credential_) { + boost::split(allowed_namespaces, namespaces_, boost::is_any_of(", "), boost::token_compress_on); +} + +bool UnityCatalog::isNamespaceAllowed(const std::string & namespace_) const +{ + return allowed_namespaces.contains("*") || allowed_namespaces.contains(namespace_); } /// getCredentialsConfigurationCallback method is supported only for S3 storage diff --git a/src/Databases/DataLake/UnityCatalog.h b/src/Databases/DataLake/UnityCatalog.h index d835ee221f78..2979e94e124e 100644 --- a/src/Databases/DataLake/UnityCatalog.h +++ b/src/Databases/DataLake/UnityCatalog.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ class UnityCatalog final : public ICatalog, private DB::WithContext const std::string & catalog_, const std::string & base_url_, const std::string & catalog_credential_, + const std::string & namespaces_, DB::ContextPtr context_); ~UnityCatalog() override = default; @@ -60,6 +62,10 @@ class UnityCatalog final : public ICatalog, private DB::WithContext Poco::Net::HTTPBasicCredentials credentials{}; + std::unordered_set allowed_namespaces; + + bool isNamespaceAllowed(const std::string & namespace_) const; + DataLake::ICatalog::Namespaces getSchemas(const std::string & base_prefix, size_t limit = 0) const; DB::Names getTablesForSchema(const std::string & schema, size_t limit = 0) const; diff --git a/src/Databases/DataLake/tests/gtest_rest_catalog_allowed_namespaces.cpp b/src/Databases/DataLake/tests/gtest_rest_catalog_allowed_namespaces.cpp new file mode 100644 index 000000000000..7a1981511c3f --- /dev/null +++ b/src/Databases/DataLake/tests/gtest_rest_catalog_allowed_namespaces.cpp @@ -0,0 +1,69 @@ +#include +#include + + +TEST(TestRestCatalogAllowedNamespaces, TestAllAllowed) +{ + DataLake::RestCatalog::AllowedNamespaces namespaces("*"); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo", /*nested*/ true)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo", /*nested*/ false)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ true)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ false)); +} + +TEST(TestRestCatalogAllowedNamespaces, TestAllBlocked) +{ + DataLake::RestCatalog::AllowedNamespaces namespaces(""); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ false)); +} + +TEST(TestRestCatalogAllowedNamespaces, TestTableInNamespaceAllowed) +{ + DataLake::RestCatalog::AllowedNamespaces namespaces("foo"); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo", /*nested*/ true)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ false)); +} + +TEST(TestRestCatalogAllowedNamespaces, TestSpecificNestedNamespaceAllowed) +{ + DataLake::RestCatalog::AllowedNamespaces namespaces("foo.bar"); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ true)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("bar", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("bar", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo.biz", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo.biz", /*nested*/ false)); +} + +TEST(TestRestCatalogAllowedNamespaces, TestNestedNamespacesAllowed) +{ + DataLake::RestCatalog::AllowedNamespaces namespaces("foo.*"); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("foo", /*nested*/ false)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ true)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ false)); +} + +TEST(TestRestCatalogAllowedNamespaces, TestTablesAndNestedNamespacesAllowed) +{ + DataLake::RestCatalog::AllowedNamespaces namespaces("foo,foo.*"); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo", /*nested*/ true)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo", /*nested*/ false)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ true)); + EXPECT_TRUE(namespaces.isNamespaceAllowed("foo.bar", /*nested*/ false)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ true)); + EXPECT_FALSE(namespaces.isNamespaceAllowed("biz", /*nested*/ false)); +} diff --git a/tests/integration/test_database_delta/test.py b/tests/integration/test_database_delta/test.py index 76ec56858eec..d4c274972df6 100644 --- a/tests/integration/test_database_delta/test.py +++ b/tests/integration/test_database_delta/test.py @@ -13,6 +13,9 @@ UC_LOG = "/var/lib/clickhouse/user_files/unitycatalog/uc.log" +CATALOG_NAME = "unity_catalog_test_db" + + def start_unity_catalog(node): node.exec_in_container( [ @@ -1026,3 +1029,42 @@ def test_varchar_char_types_via_unity_catalog(started_cluster, use_delta_kernel) .strip() ) assert row == "1\thello varchar\thello char" + + +def test_namespace_filter(started_cluster): + node = started_cluster.instances["node1"] + + # Use the same table name in all namespaces + table_name = f"table_{uuid.uuid4()}".replace("-", "_") + namespace_prefix = f"namespace_{uuid.uuid4()}_".replace("-", "_") + + + def create_namespace(suffix): + namespace = f"{namespace_prefix}{suffix}" + execute_spark_query( + node, f"CREATE SCHEMA {namespace}" + ) + execute_spark_query( + node, f"CREATE TABLE {namespace}.{table_name} (col1 int, col2 double) using Delta location '/var/lib/clickhouse/user_files/tmp/{namespace}/{table_name}'" + ) + + create_namespace("alpha"); + create_namespace("bravo"); + + node.query( + f""" + drop database if exists {CATALOG_NAME}; + create database {CATALOG_NAME} + engine DataLakeCatalog('http://localhost:8080/api/2.1/unity-catalog') + settings warehouse = 'unity', catalog_type='unity', vended_credentials=false, namespaces = '{namespace_prefix}alpha' + """, + settings={"allow_database_unity_catalog": "1"}, + ) + + assert node.query(f"SELECT name FROM system.tables WHERE database='{CATALOG_NAME}' ORDER BY name", settings={"show_data_lake_catalogs_in_system_tables": 1}) == TSV( + [ + [f"{namespace_prefix}alpha.{table_name}"], + ]) + + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}alpha.{table_name}`") == "0\n" + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}bravo.{table_name}`") diff --git a/tests/integration/test_database_glue/test.py b/tests/integration/test_database_glue/test.py index 96a5eddcb7a2..1018b0d3193d 100644 --- a/tests/integration/test_database_glue/test.py +++ b/tests/integration/test_database_glue/test.py @@ -16,6 +16,7 @@ from pyiceberg.table.sorting import SortField, SortOrder from pyiceberg.transforms import DayTransform, IdentityTransform from helpers.config_cluster import minio_access_key, minio_secret_key +from helpers.test_tools import TSV import decimal from pyiceberg.types import ( DoubleType, @@ -1129,6 +1130,45 @@ def test_check_database(started_cluster): "SYSTEM DISABLE FAILPOINT check_database_datalake_negative" ) + +def test_namespace_filter(started_cluster): + node = started_cluster.instances["node1"] + + # Use the same table name in all namespaces + table_name = f"table_{uuid.uuid4()}" + table2_name = f"table2_{uuid.uuid4()}" + namespace_prefix = f"namespace_{uuid.uuid4()}_" + + catalog = load_catalog_impl(started_cluster) + + def create_namespace(suffix): + namespace = f"{namespace_prefix}{suffix}" + catalog.create_namespace(namespace) + create_table(catalog, namespace, table_name, DEFAULT_SCHEMA, PartitionSpec(), DEFAULT_SORT_ORDER) + + create_namespace("alpha"); + create_namespace("bravo"); + + create_clickhouse_glue_database(started_cluster, node, CATALOG_NAME, + additional_settings={ + "namespaces": f"{namespace_prefix}alpha" + }) + + assert node.query(f"SELECT name FROM system.tables WHERE database='{CATALOG_NAME}' ORDER BY name", settings={"show_data_lake_catalogs_in_system_tables": 1}) == TSV( + [ + [f"{namespace_prefix}alpha.{table_name}"], + ]) + + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}alpha.{table_name}`") == "0\n" + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}bravo.{table_name}`") + + node.query(f"CREATE TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.{table2_name}` (x String) ENGINE = IcebergS3('http://minio:9000/warehouse-glue/{namespace_prefix}alpha/a1/{table2_name}/', '{minio_access_key}', '{minio_secret_key}')") + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"CREATE TABLE {CATALOG_NAME}.`{namespace_prefix}bravo.{table2_name}` (x String) ENGINE = IcebergS3('http://minio:9000/warehouse-glue/{namespace_prefix}bravo/{table2_name}/', '{minio_access_key}', '{minio_secret_key}')") + + node.query(f"DROP TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.{table_name}`") + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"DROP TABLE {CATALOG_NAME}.`{namespace_prefix}bravo.{table_name}`") + + def test_sts_smoke(started_cluster): """Test that STS authentication works with Glue catalog using role_arn and role_session_name""" node = started_cluster.instances["node1"] diff --git a/tests/integration/test_database_iceberg/test.py b/tests/integration/test_database_iceberg/test.py index 5d233f89d7ca..d48f68a36997 100644 --- a/tests/integration/test_database_iceberg/test.py +++ b/tests/integration/test_database_iceberg/test.py @@ -1124,6 +1124,7 @@ def add_column(idx): assert node.query(f"SELECT {select_cols} FROM {table_ref} ORDER BY ALL", settings=write_settings) == expected + def test_gcs(started_cluster): node = started_cluster.instances["node1"] @@ -1144,6 +1145,72 @@ def test_gcs(started_cluster): assert "Google cloud storage converts to S3" in str(err.value) +def test_namespace_filter(started_cluster): + node = started_cluster.instances["node1"] + + # Use the same table name in all namespaces + table_name = f"table_{uuid.uuid4()}" + table2_name = f"table2_{uuid.uuid4()}" + namespace_prefix = f"namespace_{uuid.uuid4()}_" + + catalog = load_catalog_impl(started_cluster) + + def create_namespace(suffix): + namespace = f"{namespace_prefix}{suffix}" + catalog.create_namespace(namespace) + create_table(catalog, namespace, table_name, DEFAULT_SCHEMA, PartitionSpec(), DEFAULT_SORT_ORDER) + + create_namespace("alpha"); + create_namespace("alpha.a1"); + create_namespace("alpha.a2"); + create_namespace("bravo"); + create_namespace("bravo.b1"); + create_namespace("charlie"); + create_namespace("charlie.c1"); + create_namespace("delta"); + create_namespace("delta.d1"); + create_namespace("delta.d2"); + create_namespace("echo"); + create_namespace("echo.e1"); + + create_clickhouse_iceberg_database(started_cluster, node, CATALOG_NAME, + additional_settings={ + "namespaces": f"{namespace_prefix}alpha,{namespace_prefix}alpha.a1,{namespace_prefix}bravo,{namespace_prefix}bravo.*,{namespace_prefix}charlie,{namespace_prefix}delta.d1,{namespace_prefix}echo.*" + }) + + assert node.query(f"SELECT name FROM system.tables WHERE database='{CATALOG_NAME}' ORDER BY name", settings={"show_data_lake_catalogs_in_system_tables": 1}) == TSV( + [ + [f"{namespace_prefix}alpha.a1.{table_name}"], + [f"{namespace_prefix}alpha.{table_name}"], + [f"{namespace_prefix}bravo.b1.{table_name}"], + [f"{namespace_prefix}bravo.{table_name}"], + [f"{namespace_prefix}charlie.{table_name}"], + [f"{namespace_prefix}delta.d1.{table_name}"], + [f"{namespace_prefix}echo.e1.{table_name}"], + ]) + + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}alpha.{table_name}`") == "0\n" + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}alpha.a1.{table_name}`") == "0\n" + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}alpha.a2.{table_name}`") + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}bravo.{table_name}`") == "0\n" + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}bravo.b1.{table_name}`") == "0\n" + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}charlie.{table_name}`") == "0\n" + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}charlie.c1.{table_name}`") + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}delta.{table_name}`") + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}delta.d1.{table_name}`") == "0\n" + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}delta.d2.{table_name}`") + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}echo.{table_name}`") + assert node.query(f"SELECT count() FROM {CATALOG_NAME}.`{namespace_prefix}echo.e1.{table_name}`") == "0\n" + + node.query(f"CREATE TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.{table2_name}` (x String) ENGINE = IcebergS3('http://minio:9000/warehouse-rest/{namespace_prefix}alpha/{table2_name}/', '{minio_access_key}', '{minio_secret_key}')") + node.query(f"CREATE TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.a1.{table2_name}` (x String) ENGINE = IcebergS3('http://minio:9000/warehouse-rest/{namespace_prefix}alpha/a1/{table2_name}/', '{minio_access_key}', '{minio_secret_key}')") + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"CREATE TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.a2.{table2_name}` (x String) ENGINE = IcebergS3('http://minio:9000/warehouse-rest/{namespace_prefix}alpha/a2/{table2_name}/', '{minio_access_key}', '{minio_secret_key}')") + + node.query(f"DROP TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.{table_name}`") + node.query(f"DROP TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.a1.{table_name}`") + assert "is filtered by `namespaces` database parameter." in node.query_and_get_error(f"DROP TABLE {CATALOG_NAME}.`{namespace_prefix}alpha.a2.{table_name}`") + + def test_invalid_auth_header_format(started_cluster): node = started_cluster.instances["node1"] From 29a2569d2ba29a34e085411959192e46c25babdb Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 14:58:13 +0300 Subject: [PATCH 15/39] DataLake namespace port: keep RestCatalog members protected The ported AllowedNamespaces block opened a public: section for the nested class and closed with private:, which downgraded every RestCatalog member after it (loadConfig, getAuthHeaders, retrieveAccessToken, ...) from protected to private. Subclasses (OneLake/BigLake/Paimon REST catalogs) call those, so Build failed with "is a private member of DataLake::RestCatalog". Restore the trailing access specifier to protected; AllowedNamespaces stays public (the gtest references it). CI: https://github.com/Altinity/ClickHouse/actions/runs/31171012685/job/92846936566 PR: https://github.com/Altinity/ClickHouse/pull/2181 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Databases/DataLake/RestCatalog.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Databases/DataLake/RestCatalog.h b/src/Databases/DataLake/RestCatalog.h index aa90d9bbdbf4..3a6739026636 100644 --- a/src/Databases/DataLake/RestCatalog.h +++ b/src/Databases/DataLake/RestCatalog.h @@ -150,7 +150,7 @@ class RestCatalog : public ICatalog, public DB::WithContext bool allow_tables = false; }; -private: +protected: AllowedNamespaces allowed_namespaces; Poco::Net::HTTPBasicCredentials credentials{}; From 065cf78cd6c101bdb2520d0f71f169f5c8ce4807 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 15:46:13 +0300 Subject: [PATCH 16/39] DataLake namespace port: fix RestCatalog ctor call in gtest_rest_catalog Adding the namespaces_ parameter to the primary RestCatalog constructor broke the pre-existing unit test gtest_rest_catalog.cpp, which constructed a RestCatalog without it (no matching constructor). Pass namespaces = "*" (allow all) in the new argument slot, before the context argument. CI: https://github.com/Altinity/ClickHouse/actions/runs/31176356154/job/92863225108 PR: https://github.com/Altinity/ClickHouse/pull/2181 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Databases/DataLake/tests/gtest_rest_catalog.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Databases/DataLake/tests/gtest_rest_catalog.cpp b/src/Databases/DataLake/tests/gtest_rest_catalog.cpp index eb1fd0db1a5c..e385d8b0629b 100644 --- a/src/Databases/DataLake/tests/gtest_rest_catalog.cpp +++ b/src/Databases/DataLake/tests/gtest_rest_catalog.cpp @@ -198,6 +198,7 @@ bool restCatalogEmpty(CatalogShape shape) /* auth_header */"", /* oauth_server_uri */"", /* oauth_server_use_request_body */false, + /* namespaces */"*", context); return catalog.empty(); From 3c82a4eb8ec6e009418865db0acf55005a7da0fe Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 17:33:07 +0300 Subject: [PATCH 17/39] Parquet v3: materialize constant column chunks as ColumnConst formOutputColumn previously expanded a constant column chunk with insertMany(Field, num_rows) - an O(rows) per-row Field-dispatch fill that showed up as a consistent ~20% UserTime increase in profiling (the baseline decoded the same near-constant chunk cheaply via RLE/dictionary). Emit a ColumnConst instead: O(1) to build, and the const-ness propagates through the pipeline. A PREWHERE/WHERE predicate computes its result from the value without expanding the stored column, and GROUP BY / aggregation over the column get a const key. The value is already in the output (post-cast) domain. The all-null block_missing_values bookkeeping is unchanged. The reader path preserves the const: getOrFormOutputColumn returns it as-is, ColumnConst::size() == rows_pass satisfies the delivery checks, and ColumnConst::filter keeps it const through multistage PREWHERE. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Processors/Formats/Impl/Parquet/Reader.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 17c35c7c2818..b047e78e0d59 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -2299,11 +2300,14 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out if (subchunk.is_constant) { - /// Constant column chunk (see detectConstantColumn): materialize the single value - /// directly in the final output type. The value is already in the output (post-cast) - /// domain, so we skip the decoded_type column and the castColumn below. - auto constant_column = output_info.output_type->createColumn(); - constant_column->insertMany(subchunk.constant_value, num_rows); + /// Constant column chunk (see detectConstantColumn): the whole chunk is a single value, + /// so materialize it as a ColumnConst rather than an expanded column. This is O(1) instead + /// of O(rows), and the const-ness propagates downstream: a PREWHERE/WHERE predicate + /// computes its result from the value without expanding the stored column, and GROUP BY / + /// aggregation over this column get a const key. The value is already in the output + /// (post-cast) domain, so we skip the decoded_type column and the castColumn below. + MutableColumnPtr single_value = output_info.output_type->createColumn(); + single_value->insert(subchunk.constant_value); /// An all-null chunk must record every row in block_missing_values, matching the normal /// decode path (which records nulls from the null map); needed for @@ -2313,7 +2317,7 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out && *output_info.idx_in_output_block < row_subgroup.block_missing_values.getNumColumns()) row_subgroup.block_missing_values.setBits(*output_info.idx_in_output_block, num_rows); - return constant_column; + return ColumnConst::create(std::move(single_value), num_rows); } res = std::move(subchunk.column); From a05174488251efcfaa78c8980172500fd77f94fb Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 19:04:05 +0300 Subject: [PATCH 18/39] Parquet v3: per-subgroup (page-level) constant-column detection [tier 2] Extends the constant-column optimization from whole-chunk (tier 1, footer statistics) to per-row-subgroup granularity using the Column Index per-page min/max/null_pages, which the reader already loads for predicate push-down. Catches columns constant over a run of pages without the whole row group being constant (common in sorted/clustered data), with no change to subgroup/chunk sizing. - constColumnMaterializationEligible: shared flat-top-level-primitive gate, factored out of detectConstantColumn. - applyColumnIndex: retain per-page constant info (value / is_const / all_null) in ColumnChunk::page_const_info. Fixed-width numeric/date/time only for value constants (the Column Index has no per-page exactness flag, so a truncated BYTE_ARRAY min==max is untrustworthy); all_null is a plain flag, always safe. - detectConstantSubchunk: a column is constant for a subgroup iff every page overlapping the subgroup row range is all-null, or all hold the same value. - Wired into intersectColumnIndexResultsAndInitSubgroups; sets the subchunk constant fields so decodePrimitiveColumn skips decode and formOutputColumn materializes a ColumnConst. New ParquetConstantColumnSubchunks ProfileEvent. Tier 1 stays the always-on baseline and the only detector for BYTE_ARRAY. Tier 2 is opportunistic: only where the Column Index is already loaded (predicate push-down columns), never force-fetched. Deferred (follow-up): skipping the prefetch of constant subgroups pages (the I/O win). Currently those pages are still fetched and skipped forward by the next subgroups skipToRowOrNextPage; correct but does not yet save the read. Design: docs/design/parquet-v3-page-level-constant-column.md Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../parquet-v3-page-level-constant-column.md | 70 +++++++ src/Common/ProfileEvents.cpp | 1 + .../Formats/Impl/Parquet/Reader.cpp | 171 ++++++++++++++++-- src/Processors/Formats/Impl/Parquet/Reader.h | 27 +++ 4 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 docs/design/parquet-v3-page-level-constant-column.md diff --git a/docs/design/parquet-v3-page-level-constant-column.md b/docs/design/parquet-v3-page-level-constant-column.md new file mode 100644 index 000000000000..218490336f93 --- /dev/null +++ b/docs/design/parquet-v3-page-level-constant-column.md @@ -0,0 +1,70 @@ +# Parquet v3: per-subgroup (page-level) constant-column detection + +## Motivation + +The current constant-column optimization (`detectConstantColumn`) fires only when a whole +Parquet **column chunk** is single-valued (footer `min == max`, no nulls). Data that is sorted or +clustered on a column is often constant over long **runs of pages** without the whole row group +being constant, so chunk-level detection misses it. + +Parquet's **Column Index** stores per-page `min_values` / `max_values` / `null_pages` / +`null_counts`. The v3 reader already loads and parses it (`applyColumnIndex`) for predicate +push-down, so per-page "is this page constant?" is available at zero extra I/O. This lets us mark a +column constant for the row range of an individual **row subgroup** (the unit that becomes one +output `Chunk`) and materialize it as a `ColumnConst`, skipping the covered pages' reads and decode. + +## Tiers (both kept) + +| Tier | Source | Always present? | Exact flag? | Granularity | +|------|--------|-----------------|-------------|-------------| +| 1 (existing) | footer `ColumnMetaData.statistics` | yes | yes (`is_min_value_exact`) | whole chunk | +| 2 (this doc) | Column Index (per page) | no (optional) | no | per subgroup | + +Tier 1 stays the always-on baseline and the **only** safe detector for `BYTE_ARRAY` / +`FIXED_LEN_BYTE_ARRAY` (Column Index has no per-page exactness flag; 16-byte truncation can make two +distinct strings compare equal). Tier 2 is opportunistic: only for fixed-width numeric/date/time, +only when the Column Index is already loaded, only for chunks tier 1 did not already mark constant. + +## Key decision: do NOT change subgroup sizing + +Aligning subgroups to page boundaries would fragment the block stream into many tiny chunks +(pages are far smaller than a row group), and a subgroup carries all columns so its size is bounded +by the non-constant columns anyway. Instead, keep subgroup boundaries exactly as today and do +**per-subgroup, per-column** detection: a column is constant for a subgroup iff every Column-Index +page overlapping the subgroup's row range is constant with the *same* value (or every such page is +`null_pages`). Chunk count is unchanged; we just catch subgroups that sit inside a constant run. + +## Phases + +- **Phase 0** — retain per-page constant info. `applyColumnIndex` currently discards the parsed + `parq::ColumnIndex`. Keep a compact per-page summary on `ColumnChunk` (value + `is_const` + + `all_null`), plus the page→`first_row_index` map already in the Offset Index. Populate only for + eligible types. +- **Phase 1** — `detectConstantSubchunk(column, column_info, [start_row, end_row))`: scan the pages + overlapping the range; return constant + value when all are `is_const` and share one value; + all-null when all are `null_pages`. +- **Phase 2** — call it in `intersectColumnIndexResultsAndInitSubgroups` for each subgroup / + primitive column that tier 1 didn't already mark constant; set `subchunk.is_constant` / + `is_all_null` / `constant_value` (the same fields `decodePrimitiveColumn` propagates). + `formOutputColumn` needs no change (already emits `ColumnConst`). +- **Phase 3** — skip work for constant subchunks: `decodePrimitiveColumn` skips decode on + `subchunk.is_constant`; `determinePagesToPrefetch` skips fetching a page only when it is constant + in **every** subgroup that overlaps it. +- **Phase 4** — stateless tests (page-run constant, all-null-per-page, byte-array negative/truncation + guard, `GROUP BY` correctness), with a new `ParquetConstantColumnSubchunks` ProfileEvent to prove + tier 2 fired. +- **Phase 5** — ProfileEvents comparison on clustered data: expect further `S3GetObject` / + `ParquetFetchWaitTimeMicroseconds` drops with chunk count unchanged. + +## Guardrails + +- Fixed-width numeric/date/time only (truncation). +- Reuse the Column Index only when already loaded; never force-fetch it just for this. +- Gate on the existing `input_format_parquet_use_constant_column_optimization` setting. +- Partial-page subgroup boundaries are fine: a partial overlap of a constant page still yields that + value, as long as every overlapping page is constant with the shared value. + +## Out of scope + +In-subgroup partial fill for constant runs *shorter* than a subgroup (skip only those pages' bytes, +yield a full column) — a later follow-up. diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 6d38d90e0549..cd833a9bb1ad 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1443,6 +1443,7 @@ The server successfully detected this situation and will download merged part fr M(ParquetReadRowGroups, "The total number of row groups read from parquet data", ValueType::Number) \ M(ParquetPrunedRowGroups, "The total number of row groups pruned from parquet data", ValueType::Number) \ M(ParquetConstantColumnChunks, "The total number of parquet column chunks materialized from a single value in their min/max statistics, without reading their data pages", ValueType::Number) \ + M(ParquetConstantColumnSubchunks, "The total number of parquet column subchunks (per row subgroup) materialized from a single value in their per-page column-index statistics, without decoding their data pages", ValueType::Number) \ M(ParquetDecodingTasks, "Tasks issued by parquet reader", ValueType::Number) \ M(ParquetDecodingTaskBatches, "Task groups sent to a thread pool by parquet reader", ValueType::Number) \ M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index b047e78e0d59..3a668260618a 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -42,6 +42,7 @@ namespace ProfileEvents extern const Event ParquetRowsFilterExpression; extern const Event ParquetColumnsFilterExpression; extern const Event ParquetConstantColumnChunks; + extern const Event ParquetConstantColumnSubchunks; } namespace DB::Parquet @@ -970,6 +971,18 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & (column_index.__isset.null_counts && column_index.null_counts.size() != num_pages)) throw Exception(ErrorCodes::INCORRECT_DATA, "Unexpected number of pages: {} null_pages, {} null_counts, {} min_values, {} max_values, {} pages in offset index", column_index.null_pages.size(), column_index.null_counts.size(), column_index.min_values.size(), column_index.max_values.size(), num_pages); + /// Tier 2 constant-column detection: retain per-page constant info from the Column Index for + /// use by detectConstantSubchunk. Only when eligible and the whole chunk is not already + /// constant (tier 1). For truncatable physical types the Column Index has no per-page + /// exactness flag, so a per-page min == max is untrustworthy: record only `all_null` there. + const bool record_page_const = + constColumnMaterializationEligible(column_info) && !column.is_constant; + const bool may_be_truncated = + column.meta->meta_data.type == parq::Type::BYTE_ARRAY + || column.meta->meta_data.type == parq::Type::FIXED_LEN_BYTE_ARRAY; + if (record_page_const) + column.page_const_info.assign(num_pages, ColumnChunk::PageConstInfo{}); + Hyperrectangle hyperrectangle(extended_sample_block.columns(), Range::createWholeUniverse()); size_t prev_row_idx = 0; // start of the latest range of rows that pass filter for (size_t page_idx = 0; page_idx < num_pages; ++page_idx) @@ -980,6 +993,9 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & bool always_null = !column_index.null_pages.empty() && column_index.null_pages[page_idx]; bool can_be_null = !column_index.__isset.null_counts || column_index.null_counts[page_idx] != 0; + if (record_page_const) + column.page_const_info[page_idx].all_null = always_null; + if (nullable && always_null) { /// Single-point range containing either the default value or one of the infinities. @@ -993,6 +1009,16 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & column_info.decoder.decodeField(column_index.min_values[page_idx], /*is_max=*/ false, range.left); column_info.decoder.decodeField(column_index.max_values[page_idx], /*is_max=*/ true, range.right); + /// A page with no nulls whose decoded min == max holds a single value (post-cast + /// output domain, like tier 1). Captured before adjustRangeFromIndexIfNeeded, which + /// mutates the range for null/default handling. + if (record_page_const && !may_be_truncated && !can_be_null + && !range.left.isNull() && range.left == range.right) + { + column.page_const_info[page_idx].is_const = true; + column.page_const_info[page_idx].value = range.left; + } + adjustRangeFromIndexIfNeeded(range, column_info, can_be_null); } @@ -1162,6 +1188,34 @@ void Reader::intersectColumnIndexResultsAndInitSubgroups(RowGroup & row_group) } if (options.format.defaults_for_omitted_fields) row_subgroup.block_missing_values.init(sample_block->columns()); + + /// Tier 2 constant detection: for each primitive column not already whole-chunk-constant + /// (tier 1), check whether it is constant over this subgroup's row range using the + /// per-page Column Index info retained in applyColumnIndex. If so, mark the subchunk so + /// decodePrimitiveColumn skips decoding it and formOutputColumn materializes a ColumnConst. + /// The subgroup's pages are still skipped forward by the next subgroup's + /// skipToRowOrNextPage; skipping the prefetch itself is a separate follow-up. + if (row_group.need_to_process) + { + for (size_t i = 0; i < primitive_columns.size(); ++i) + { + ColumnChunk & column = row_group.columns[i]; + if (column.is_constant) + continue; // tier 1 already materializes the whole chunk + bool all_null = false; + Field value; + if (detectConstantSubchunk( + column, primitive_columns[i], substart, subend, + size_t(row_group.meta->num_rows), all_null, value)) + { + ColumnSubchunk & subchunk = row_subgroup.columns[i]; + subchunk.is_constant = true; + subchunk.is_all_null = all_null; + subchunk.constant_value = std::move(value); + ProfileEvents::increment(ProfileEvents::ParquetConstantColumnSubchunks); + } + } + } } } row_group.intersected_row_ranges_after_column_index = std::move(row_ranges); @@ -1342,13 +1396,13 @@ double Reader::estimateColumnMemoryBytesPerRow(const ColumnChunk & column, const return res; } -void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const +bool Reader::constColumnMaterializationEligible(const PrimitiveColumnInfo & column_info) const { if (!options.format.parquet.use_constant_column_optimization) - return; - /// We rely on column chunk min/max statistics being both present and decodable. + return false; + /// We rely on min/max statistics being both present and decodable. if (!column_info.decoder.allow_stats) - return; + return false; /// Only flat, top-level primitive columns, so that one parquet value maps 1:1 to one output row /// and formOutputColumn can materialize the value directly. Exclude: @@ -1356,13 +1410,20 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf /// - leaves nested inside a Tuple/Map/Array output column (the output column is not primitive; /// this also covers physically-nullable structs, whose leaves are not primitive outputs). /// A plain Nullable(T) is fine: it adds a definition level but no repetition, and its output - /// column is still primitive; the no-nulls check below and the output_nullable wrap handle it. + /// column is still primitive; the no-nulls check and the output_nullable wrap handle it. if (column_info.levels.back().rep != 0 || column_info.max_array_def != 0) - return; + return false; if (column_info.idx_in_output_block >= sample_block_to_output_columns_idx.size()) - return; + return false; const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); if (!output_idx.has_value() || !output_columns[output_idx.value()].is_primitive) + return false; + return true; +} + +void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const +{ + if (!constColumnMaterializationEligible(column_info)) return; const auto & meta_data = column.meta->meta_data; @@ -1431,20 +1492,102 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); } +bool Reader::detectConstantSubchunk( + const ColumnChunk & column, const PrimitiveColumnInfo & column_info, + size_t start_row, size_t end_row, size_t row_group_num_rows, + bool & out_all_null, Field & out_value) const +{ + if (column.page_const_info.empty()) + return false; + const size_t num_pages = column.offset_index.page_locations.size(); + if (num_pages == 0 || column.page_const_info.size() != num_pages) + return false; + + bool any = false; + bool all_pages_null = true; + bool all_pages_const_same = true; + Field value; + bool value_set = false; + + for (size_t p = 0; p < num_pages; ++p) + { + const size_t p_start = static_cast(column.offset_index.page_locations[p].first_row_index); + const size_t p_end = (p + 1 < num_pages) + ? static_cast(column.offset_index.page_locations[p + 1].first_row_index) + : row_group_num_rows; + if (p_end <= start_row || p_start >= end_row) + continue; // page does not overlap the subgroup's row range + + any = true; + const auto & pci = column.page_const_info[p]; + if (!pci.all_null) + all_pages_null = false; + if (pci.is_const) + { + if (!value_set) + { + value = pci.value; + value_set = true; + } + else if (value != pci.value) + all_pages_const_same = false; + } + else + { + all_pages_const_same = false; + } + } + + if (!any) + return false; + + if (all_pages_null) + { + /// Every overlapping page is entirely null - mirror detectConstantColumn's all-null branch. + const bool null_as_default = options.format.null_as_default && !column_info.output_nullable; + if (column_info.output_nullable) + out_value = Null{}; + else if (null_as_default) + out_value = column_info.output_type->getDefault(); + else + return false; // non-nullable output without null substitution: let normal decode error + out_all_null = true; + return true; + } + + if (all_pages_const_same && value_set) + { + out_all_null = false; + out_value = value; + return true; + } + + return false; +} + void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff) { - if (column.is_constant) + /// subchunk.is_constant is set either here from column.is_constant (tier 1, whole chunk) or + /// during subgroup init by detectConstantSubchunk (tier 2, this row subgroup). Either way we skip + /// decoding and let formOutputColumn materialize the value as a ColumnConst. + if (column.is_constant && !subchunk.is_constant) { - /// This chunk provably holds a single value in every row (see detectConstantColumn), and its - /// data pages were never fetched. Skip all decoding and hand the already-decoded value to - /// formOutputColumn, which materializes it directly in the final output type. The value is - /// in the output (post-cast) domain, so it must not go through the decoded_type column and - /// castColumn path. We still run the per-output-column bookkeeping below so the output column - /// is formed once the last of its primitive columns is done. subchunk.is_constant = true; subchunk.constant_value = column.constant_value; subchunk.is_all_null = column.is_all_null; + } + if (subchunk.is_constant) + { + /// This subchunk provably holds a single value in every row - either the whole chunk (tier 1, + /// detectConstantColumn; its data pages were never fetched) or just this subgroup's row range + /// (tier 2, detectConstantSubchunk; the pages are skipped forward by the next subgroup's + /// skipToRowOrNextPage). Skip decoding and hand the value to formOutputColumn, which + /// materializes it directly in the final output type. The value is in the output (post-cast) + /// domain, so it must not go through the decoded_type column and castColumn path. We still run + /// the per-output-column bookkeeping below so the output column is formed once the last of its + /// primitive columns is done. (The subchunk fields were set above for tier 1, or during + /// subgroup init for tier 2.) OutputColumnState & state = row_subgroup.output.at(column_info.idx_in_output_block); chassert(!state.column); size_t prev_count = state.primitive_columns_remaining.fetch_sub(1); diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 49bc332d4661..c36fe154cca6 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -309,6 +309,19 @@ struct Reader /// records every row in block_missing_values (the plain constant case has no nulls). bool is_all_null = false; + /// Per-page constant info from the Column Index (tier 2; see applyColumnIndex and + /// detectConstantSubchunk). Populated only when the column index is loaded, indexed by data + /// page. `value` is in the output (post-cast) domain. Empty when unavailable. For truncatable + /// physical types (BYTE_ARRAY / FIXED_LEN_BYTE_ARRAY) only `all_null` is trusted, never + /// `is_const` (the Column Index has no per-page exactness flag). + struct PageConstInfo + { + bool is_const = false; /// page holds a single non-null value + bool all_null = false; /// page is entirely null + Field value; /// the value when is_const + }; + std::vector page_const_info; + /// Prefetches. /// TODO [parquet]: Check that all handles and tokens are reset after correct stages. PrefetchHandle bloom_filter_header_prefetch; @@ -541,6 +554,20 @@ struct Reader /// conditions. void detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const; + /// Shared eligibility gate for constant-column materialization (both tiers): the setting is on, + /// stats are decodable, and the leaf is a flat top-level primitive output column. + bool constColumnMaterializationEligible(const PrimitiveColumnInfo & column_info) const; + + /// Tier 2 (per row subgroup): using the retained per-page Column Index info + /// (ColumnChunk::page_const_info), decide whether `column` is constant over the row range + /// [start_row, end_row) covered by a subgroup. Returns true and sets out_all_null / out_value + /// when every overlapping page is all-null, or every overlapping page holds the same single + /// value. row_group_num_rows is the row group's total row count (for the last page's end). + bool detectConstantSubchunk( + const ColumnChunk & column, const PrimitiveColumnInfo & column_info, + size_t start_row, size_t end_row, size_t row_group_num_rows, + bool & out_all_null, Field & out_value) const; + /// Returns mutable column because some of the recursive calls require it, /// e.g. ColumnArray::create does assumeMutable() on the nested columns. /// Moves the column out of ColumnSubchunk-s, leaving nullptrs in ColumnSubchunk::column. From c37b8ab415ef3dc2ab823997f6c1640bfc702033 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 19:11:10 +0300 Subject: [PATCH 19/39] Parquet v3: skip prefetch of constant subgroup pages [tier 2 phase 3] Completes tier-2: a per-subgroup constant column (detectConstantSubchunk) now also skips fetching its data pages, not just decoding them. In determinePagesToPrefetch, a constant subchunk claims no pages; a page fully inside it is released (never fetched), while a page shared with a neighbouring non-constant subgroup is left for that subgroup to claim. Safe because tier 2 only exists when the Column Index (hence Offset Index) is loaded: a constant subgroup never calls skipToRowOrNextPage, and a non-constant subgroup jumps directly via the offset index to its own claimed pages, so a released constant page between them is never accessed. The whole-chunk data_pages_prefetch is still split (likely_to_be_used=false), so only claimed pages are actually read. This turns the tier-2 CPU/memory win into an I/O win as well (fewer S3 GETs / ParquetPrefetcherReadRandomRead), matching tier 1 but at page granularity. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Processors/Formats/Impl/Parquet/ReadManager.cpp | 2 +- src/Processors/Formats/Impl/Parquet/Reader.cpp | 11 ++++++++++- src/Processors/Formats/Impl/Parquet/Reader.h | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index 0698abc070a3..bcf097d14a49 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -797,7 +797,7 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif /// pool and charges the compressed bytes to the ColumnDataPrefetch stage budget - /// separate from the decoded-output budget (ColumnData) - so many row groups can have /// their reads in flight (deep prefetch) while only a few are decoded at once. - reader.determinePagesToPrefetch(column, row_subgroup, row_group, prefetches); + reader.determinePagesToPrefetch(column, row_subgroup.columns.at(task.column_idx), row_subgroup, row_group, prefetches); /// Side note: would be nice to avoid reading the dictionary if all dictionary-encoded /// pages were filtered out (e.g. if it's a 100 MB column chunk with unique long strings, diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 3a668260618a..d5765f3c77e6 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1254,7 +1254,7 @@ void Reader::decodeOffsetIndex(ColumnChunk & column, const RowGroup & row_group) } } -void Reader::determinePagesToPrefetch(ColumnChunk & column, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out) +void Reader::determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk & subchunk, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out) { chassert(row_subgroup.filter.rows_pass > 0); if (column.is_constant) @@ -1323,6 +1323,15 @@ void Reader::determinePagesToPrefetch(ColumnChunk & column, const RowSubgroup & if (passes_filter && row_subgroup.filter.rows_pass < row_subgroup.filter.rows_total) passes_filter = !memoryIsZero(row_subgroup.filter.filter.data(), start_row_idx - row_subgroup.start_row_idx, end_row_idx - row_subgroup.start_row_idx); + /// Tier 2: this subgroup's column is constant (detectConstantSubchunk), so it decodes nothing + /// and needs no pages. Treat every page as not-needed-by-this-subgroup; a page fully inside + /// this subgroup is then released below (never fetched), while a page shared with a + /// neighbouring non-constant subgroup is left for that subgroup to claim. Safe because a + /// constant subgroup never calls skipToRowOrNextPage, and non-constant subgroups jump + /// directly to their own (claimed) pages via the offset index. + if (subchunk.is_constant) + passes_filter = false; + if (passes_filter) out.push_back(&page.prefetch); // this subgroup needs this page else if (page.end_row_idx > subgroup_end) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index c36fe154cca6..2540b7ffec86 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -539,7 +539,7 @@ struct Reader /// Call after prewhere is done on row subgroup. Un-requests prefetch for fully filtered out pages, /// adds pages that need prefetch to `out`. Must be called in order. /// May assign dictionary_page_prefetch. - void determinePagesToPrefetch(ColumnChunk & column, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out); + void determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk & subchunk, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out); /// Guess how much memory ColumnSubchunk::{column, arrays_offsets} will use, per row. double estimateColumnMemoryBytesPerRow(const ColumnChunk & column, const RowGroup & row_group, const PrimitiveColumnInfo & column_info) const; From 60841f9decd4f58d64d7bb0fd0b2cbb2882f00f4 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 19:24:42 +0300 Subject: [PATCH 20/39] Parquet v3: optional force-load of column index for constant detection Adds input_format_parquet_use_column_index_for_constant_columns (default off). By default tier-2 per-subgroup constant detection only runs where the Column Index is already loaded (columns with a predicate). This setting force-loads the Column Index (and Offset Index) for eligible read columns without a predicate, so tier-2 can skip single-valued page runs on them too - worthwhile for sorted / low-cardinality columns, at the cost of a small extra (tail-contiguous, coalesced) index read. A force-loaded column takes the same load path as a predicate column (use_column_index = true); applyColumnIndex records per-page constant info but skips page-level predicate pruning when the column has no condition (prev_row_idx stays 0 -> whole chunk selected -> no restriction). Setting is declared in FormatFactorySettings, plumbed through FormatSettings / FormatFactory, and recorded in SettingsChangesHistory (26.6). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../parquet-v3-page-level-constant-column.md | 16 ++++++- src/Core/FormatFactorySettings.h | 3 ++ src/Core/SettingsChangesHistory.cpp | 1 + src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 1 + .../Formats/Impl/Parquet/Reader.cpp | 44 +++++++++++++------ 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/docs/design/parquet-v3-page-level-constant-column.md b/docs/design/parquet-v3-page-level-constant-column.md index 218490336f93..36970a2effc7 100644 --- a/docs/design/parquet-v3-page-level-constant-column.md +++ b/docs/design/parquet-v3-page-level-constant-column.md @@ -56,11 +56,23 @@ page overlapping the subgroup's row range is constant with the *same* value (or - **Phase 5** — ProfileEvents comparison on clustered data: expect further `S3GetObject` / `ParquetFetchWaitTimeMicroseconds` drops with chunk count unchanged. +## Optional force-load + +By default tier 2 only uses the Column Index when it is already loaded (columns with a predicate +push-down). `input_format_parquet_use_column_index_for_constant_columns` (default off) extends it: +the Column Index (+ Offset Index) is force-loaded for eligible read columns that have no predicate, +so tier 2 can also fire on them. Cost is a small extra read of the (tiny, tail-contiguous, coalesced) +index; worthwhile mainly for sorted / low-cardinality columns. `applyColumnIndex` records per-page +constant info but skips predicate pruning when the column has no condition. A future `auto` mode +could gate this on footer signals (row-group `sorting_columns`, low compressed-bytes-per-value, +dictionary encoding stats) instead of an all-or-nothing switch. + ## Guardrails - Fixed-width numeric/date/time only (truncation). -- Reuse the Column Index only when already loaded; never force-fetch it just for this. -- Gate on the existing `input_format_parquet_use_constant_column_optimization` setting. +- Gate on the existing `input_format_parquet_use_constant_column_optimization` setting; the + force-load above is additionally gated by + `input_format_parquet_use_column_index_for_constant_columns`. - Partial-page subgroup boundaries are fine: a partial overlap of a constant page still yields that value, as long as every overlapping page is constant with the shared value. diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index de43d5787fee..d53480a7e337 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -209,6 +209,9 @@ Minor tweak to how pages are read from parquet file when no page filtering is us )", 0) \ DECLARE(Bool, input_format_parquet_use_constant_column_optimization, true, R"( When a Parquet column chunk provably holds a single value in every row (according to its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages. +)", 0) \ + DECLARE(Bool, input_format_parquet_use_column_index_for_constant_columns, false, R"( +Load the Parquet Column Index for read columns that have no predicate of their own, so the constant-column optimization can also skip data pages that are single-valued over a row subgroup (not just over a whole column chunk). Costs a small extra read of the (tiny) Column Index; only worthwhile when columns are sorted or low-cardinality. Applies only when `input_format_parquet_use_constant_column_optimization` is enabled. )", 0) \ DECLARE(Double, input_format_parquet_prefetch_bandwidth_hide_seconds, 0, R"( Read back-pressure for the Parquet v3 reader. When greater than zero, stop prefetching more compressed data pages ahead of decoding once the in-flight compressed bytes exceed this many seconds' worth of the measured read throughput (i.e. once the storage link is kept busy). Prevents buffering compressed data far beyond what bandwidth can consume. 0 disables the back-pressure (compressed prefetch is then bounded only by its memory budget). diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 9dbeae45e4fa..de8fe13f6b96 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -43,6 +43,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() { {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, {"input_format_parquet_prefetch_bandwidth_hide_seconds", 0, 0, "New setting: read back-pressure for the Parquet v3 reader; stop prefetching compressed data pages once in-flight compressed bytes exceed this many seconds of measured read throughput. 0 (the default and the pre-existing behavior) disables the back-pressure."}, + {"input_format_parquet_use_column_index_for_constant_columns", false, false, "New setting: load the Parquet Column Index for read columns without a predicate so the constant-column optimization can skip data pages that are single-valued over a row subgroup. Disabled by default (costs a small extra read)."}, {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, {"output_format_image_width", 1024, 1024, "New setting controlling the width of the output image for image output formats such as PNG."}, diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index c964cbd6a5d6..87e8b6a81752 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -220,6 +220,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.page_filter_push_down = settings[Setting::input_format_parquet_page_filter_push_down]; format_settings.parquet.use_offset_index = settings[Setting::input_format_parquet_use_offset_index]; format_settings.parquet.use_constant_column_optimization = settings[Setting::input_format_parquet_use_constant_column_optimization]; + format_settings.parquet.use_column_index_for_constant_columns = settings[Setting::input_format_parquet_use_column_index_for_constant_columns]; format_settings.parquet.prefetch_bandwidth_hide_seconds = settings[Setting::input_format_parquet_prefetch_bandwidth_hide_seconds]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 5ecd73920853..1b09130780f0 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -350,6 +350,7 @@ struct FormatSettings bool page_filter_push_down = true; bool use_offset_index = true; bool use_constant_column_optimization = true; + bool use_column_index_for_constant_columns = false; double prefetch_bandwidth_hide_seconds = 0; bool enable_json_parsing = true; diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index d5765f3c77e6..5aadf2c78254 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -604,8 +604,18 @@ void Reader::initializePrefetches() /// nor the column index nor the data pages. The row group already passed the key /// condition via its min == max hyperrectangle, so page-level pruning is redundant here. + /// Force-load the column index (per-page stats) for an eligible column even without a + /// predicate on it, so tier-2 constant detection (detectConstantSubchunk) can run. Gated + /// by input_format_parquet_use_column_index_for_constant_columns. Requires the offset + /// index too (page -> row mapping), loaded just below. + const bool force_column_index_for_const = + options.format.parquet.use_column_index_for_constant_columns + && !column.is_constant + && constColumnMaterializationEligible(primitive_columns[column_idx]) + && column.meta->__isset.column_index_offset && column.meta->__isset.column_index_length; + /// Offset index. - if (use_offset_index && !column.is_constant && + if ((use_offset_index || force_column_index_for_const) && !column.is_constant && column.meta->__isset.offset_index_offset && column.meta->__isset.offset_index_length) { column.offset_index_prefetch = prefetcher.registerRange( @@ -615,7 +625,7 @@ void Reader::initializePrefetches() /// Column index. column.use_column_index = !column.is_constant - && primitive_columns[column_idx].column_index_condition + && (primitive_columns[column_idx].column_index_condition || force_column_index_for_const) && column.offset_index_prefetch && column.meta->__isset.column_index_offset && column.meta->__isset.column_index_length; if (column.use_column_index) @@ -957,7 +967,9 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & try { chassert(column.use_column_index); - chassert(column_info.column_index_condition); + /// column_index_condition may be null when the column index was force-loaded only for tier-2 + /// constant detection (input_format_parquet_use_column_index_for_constant_columns); in that + /// case we record per-page constant info below but do no page-level predicate pruning. auto data = prefetcher.getRangeData(column.column_index_prefetch); parq::ColumnIndex column_index; @@ -1022,17 +1034,23 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & adjustRangeFromIndexIfNeeded(range, column_info, can_be_null); } - bool passes_filter = column_info.column_index_condition->checkInHyperrectangle( - hyperrectangle, extended_sample_block_data_types).can_be_true; - - if (!passes_filter) + /// Page-level predicate pruning only when this column has a condition. When the index was + /// force-loaded solely for constant detection, prev_row_idx stays 0 and the whole chunk is + /// selected (the final range below), i.e. no restriction. + if (column_info.column_index_condition) { - size_t start_row = column.offset_index.page_locations[page_idx].first_row_index; - size_t end_row = page_idx + 1 < num_pages ? column.offset_index.page_locations[page_idx + 1].first_row_index : row_group.meta->num_rows; - chassert(end_row > start_row); // validated in decodeOffsetIndex - if (start_row > prev_row_idx) - column.row_ranges_after_column_index.emplace_back(prev_row_idx, start_row); - prev_row_idx = end_row; + bool passes_filter = column_info.column_index_condition->checkInHyperrectangle( + hyperrectangle, extended_sample_block_data_types).can_be_true; + + if (!passes_filter) + { + size_t start_row = column.offset_index.page_locations[page_idx].first_row_index; + size_t end_row = page_idx + 1 < num_pages ? column.offset_index.page_locations[page_idx + 1].first_row_index : row_group.meta->num_rows; + chassert(end_row > start_row); // validated in decodeOffsetIndex + if (start_row > prev_row_idx) + column.row_ranges_after_column_index.emplace_back(prev_row_idx, start_row); + prev_row_idx = end_row; + } } } From e13b115dc1925ad6313fdf7cf63635a6e2cfe51b Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 19:57:33 +0300 Subject: [PATCH 21/39] Parquet v3: mixed-topology constant-page fill [experimental, default off] Approach B: when only PART of a row subgroup is single-valued (a constant run shorter than, or straddling, a subgroup), fill those pages from the per-page Column Index while decoding only the varying pages - producing a full column where whole-subgroup tier-2 cannot make a ColumnConst. Gated behind input_format_parquet_fill_constant_pages (default off). Additional gates keep it correct and simple: - output column must not need a post-decode cast (we fill the decoded_type column with the Column Index value, valid only when decoded == output value type); - no predicate on the column (so no page pruning; data_pages == all pages); - no prewhere filtering in the subgroup (avoids filter/range intersection); - at least one single-value page and no all-null page in the subgroup (all-null pages fall back to the standard decode). fillConstantPagesAndDecodeRest walks the subgroup page by page: a single-value page is filled via insertMany (+ null-map zeros) without reading it; a varying page is decoded with the existing skipToRowOrNextPage + readRowsInPage, which jumps over the filled pages via the offset index without loading them. First cut is decode-fill only: constant pages are still prefetched (correct, some wasted I/O). Skipping their prefetch needs cross-subgroup coordination and is a follow-up. Design: docs/design/parquet-v3-page-level-constant-column.md Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Core/FormatFactorySettings.h | 3 + src/Core/SettingsChangesHistory.cpp | 1 + src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 1 + .../Formats/Impl/Parquet/Reader.cpp | 118 +++++++++++++++++- src/Processors/Formats/Impl/Parquet/Reader.h | 10 ++ 6 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index d53480a7e337..052e8dbadde1 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -212,6 +212,9 @@ When a Parquet column chunk provably holds a single value in every row (accordin )", 0) \ DECLARE(Bool, input_format_parquet_use_column_index_for_constant_columns, false, R"( Load the Parquet Column Index for read columns that have no predicate of their own, so the constant-column optimization can also skip data pages that are single-valued over a row subgroup (not just over a whole column chunk). Costs a small extra read of the (tiny) Column Index; only worthwhile when columns are sorted or low-cardinality. Applies only when `input_format_parquet_use_constant_column_optimization` is enabled. +)", 0) \ + DECLARE(Bool, input_format_parquet_fill_constant_pages, false, R"( +Experimental. When a Parquet column is single-valued over some data pages but not the whole row subgroup, fill those pages' rows from the per-page Column Index statistics instead of reading and decoding them (mixed-topology subgroups). Extends the constant-column optimization below the subgroup granularity. Requires `input_format_parquet_use_constant_column_optimization`; disabled by default. )", 0) \ DECLARE(Double, input_format_parquet_prefetch_bandwidth_hide_seconds, 0, R"( Read back-pressure for the Parquet v3 reader. When greater than zero, stop prefetching more compressed data pages ahead of decoding once the in-flight compressed bytes exceed this many seconds' worth of the measured read throughput (i.e. once the storage link is kept busy). Prevents buffering compressed data far beyond what bandwidth can consume. 0 disables the back-pressure (compressed prefetch is then bounded only by its memory budget). diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index de8fe13f6b96..6fe9aa834bc2 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -44,6 +44,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, {"input_format_parquet_prefetch_bandwidth_hide_seconds", 0, 0, "New setting: read back-pressure for the Parquet v3 reader; stop prefetching compressed data pages once in-flight compressed bytes exceed this many seconds of measured read throughput. 0 (the default and the pre-existing behavior) disables the back-pressure."}, {"input_format_parquet_use_column_index_for_constant_columns", false, false, "New setting: load the Parquet Column Index for read columns without a predicate so the constant-column optimization can skip data pages that are single-valued over a row subgroup. Disabled by default (costs a small extra read)."}, + {"input_format_parquet_fill_constant_pages", false, false, "New experimental setting: fill single-valued Parquet data pages from per-page Column Index statistics instead of decoding them, even when only part of a row subgroup is constant. Disabled by default."}, {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, {"output_format_image_width", 1024, 1024, "New setting controlling the width of the output image for image output formats such as PNG."}, diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index 87e8b6a81752..0a978feb83ac 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -221,6 +221,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.use_offset_index = settings[Setting::input_format_parquet_use_offset_index]; format_settings.parquet.use_constant_column_optimization = settings[Setting::input_format_parquet_use_constant_column_optimization]; format_settings.parquet.use_column_index_for_constant_columns = settings[Setting::input_format_parquet_use_column_index_for_constant_columns]; + format_settings.parquet.fill_constant_pages = settings[Setting::input_format_parquet_fill_constant_pages]; format_settings.parquet.prefetch_bandwidth_hide_seconds = settings[Setting::input_format_parquet_prefetch_bandwidth_hide_seconds]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 1b09130780f0..d1fd824a3de9 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -351,6 +351,7 @@ struct FormatSettings bool use_offset_index = true; bool use_constant_column_optimization = true; bool use_column_index_for_constant_columns = false; + bool fill_constant_pages = false; double prefetch_bandwidth_hide_seconds = 0; bool enable_json_parsing = true; diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 5aadf2c78254..168551d8327a 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1592,6 +1592,108 @@ bool Reader::detectConstantSubchunk( return false; } +bool Reader::willFillConstantPages( + const ColumnChunk & column, const PrimitiveColumnInfo & column_info, + const RowGroup & row_group, const RowSubgroup & row_subgroup) const +{ + if (!options.format.parquet.fill_constant_pages) + return false; + if (column.is_constant) + return false; // whole chunk already constant (tier 1) + if (column.page_const_info.empty()) + return false; // no Column Index info retained + if (column_info.column_index_condition) + return false; // a predicate on this column may have pruned pages; keep the standard path + if (!constColumnMaterializationEligible(column_info)) + return false; + if (row_subgroup.filter.rows_pass != row_subgroup.filter.rows_total) + return false; // some rows filtered out in this subgroup; avoid filter/range intersection + + /// We fill the decoded_type column with the output-domain value from the Column Index, which is + /// only valid when no post-decode cast is applied (decoded_type == output value type). + const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); + if (!output_idx.has_value() || output_columns[output_idx.value()].needs_cast) + return false; + + const auto & pages = column.offset_index.page_locations; + const size_t num_pages = pages.size(); + if (num_pages == 0 || column.page_const_info.size() != num_pages) + return false; + + /// Require at least one single-value page and no all-null page overlapping the subgroup (all-null + /// pages are not handled by the fill; fall back to the standard decode for those subgroups). + const size_t start = row_subgroup.start_row_idx; + const size_t end = start + row_subgroup.filter.rows_total; + const size_t rg_rows = size_t(row_group.meta->num_rows); + bool any_const = false; + for (size_t p = 0; p < num_pages; ++p) + { + const size_t p_start = size_t(pages[p].first_row_index); + const size_t p_end = (p + 1 < num_pages) ? size_t(pages[p + 1].first_row_index) : rg_rows; + if (p_end <= start || p_start >= end) + continue; + const auto & pci = column.page_const_info[p]; + if (pci.all_null) + return false; + if (pci.is_const) + any_const = true; + } + return any_const; +} + +void Reader::fillConstantPagesAndDecodeRest( + ColumnChunk & column, const PrimitiveColumnInfo & column_info, + ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup) +{ + const size_t start = row_subgroup.start_row_idx; + const size_t end = start + row_subgroup.filter.rows_total; + const auto & pages = column.offset_index.page_locations; + const size_t num_pages = pages.size(); + const size_t rg_rows = size_t(row_group.meta->num_rows); + + ColumnUInt8::Container * null_map_data = subchunk.null_map + ? &assert_cast(*subchunk.null_map).getData() + : nullptr; + + /// Position at the first page overlapping `start`. + size_t p = 0; + while (p < num_pages && (p + 1 < num_pages ? size_t(pages[p + 1].first_row_index) : rg_rows) <= start) + ++p; + + size_t row = start; + while (row < end) + { + chassert(p < num_pages); + const size_t p_end = (p + 1 < num_pages) ? size_t(pages[p + 1].first_row_index) : rg_rows; + const size_t seg_end = std::min(end, p_end); + chassert(seg_end > row); + const size_t count = seg_end - row; + const auto & pci = column.page_const_info[p]; + + if (pci.is_const) + { + /// Fill the single value without reading the page. Value is in the output value domain, + /// which equals decoded_type here (willFillConstantPages required no cast). A single-value + /// page has no nulls, so any null map gets zeros for these rows. + subchunk.column->insertMany(pci.value, count); + if (null_map_data) + null_map_data->resize_fill(null_map_data->size() + count, 0); + } + else + { + /// Varying page: decode [row, seg_end) via the normal machinery. skipToRowOrNextPage jumps + /// to this page (via the offset index), stepping over any filled const pages without + /// loading them; readRowsInPage appends the decoded values (and null-map entries). + skipToRowOrNextPage(row, column, column_info); + readRowsInPage(seg_end, subchunk, column, column_info, &row_subgroup); + } + + row = seg_end; + if (row == p_end) + ++p; + } +} + void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff) { /// subchunk.is_constant is set either here from column.is_constant (tier 1, whole chunk) or @@ -1661,6 +1763,16 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn string_column->getChars().reserve(bytes_to_reserve); } + /// Mixed-topology fill (input_format_parquet_fill_constant_pages): when only part of this + /// subgroup is single-valued, fill the constant pages from the Column Index and decode only the + /// varying ones. Produces a full decoded_type column, so it replaces the decode loop below. + bool filled_constant_pages = false; + if (willFillConstantPages(column, column_info, row_group, row_subgroup)) + { + fillConstantPagesAndDecodeRest(column, column_info, subchunk, row_group, row_subgroup); + filled_constant_pages = true; + } + /// Find ranges of rows that pass filter and decode them. /// When we have per-page prefetches (offset index), some pages may have had their prefetch @@ -1684,7 +1796,11 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn !column.need_null_map; const size_t subgroup_end_row_idx = row_subgroup.start_row_idx + row_subgroup.filter.rows_total; - if (use_filter_in_decoder) + if (filled_constant_pages) + { + /// Already produced the whole subchunk column above; skip the normal decode loop. + } + else if (use_filter_in_decoder) { skipToRowOrNextPage(row_subgroup.start_row_idx, column, column_info); diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 2540b7ffec86..7daab1049be9 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -568,6 +568,16 @@ struct Reader size_t start_row, size_t end_row, size_t row_group_num_rows, bool & out_all_null, Field & out_value) const; + /// Mixed-topology fill (input_format_parquet_fill_constant_pages): true when this subgroup has + /// some single-value pages (and no all-null pages) that can be filled from the Column Index + /// while the rest is decoded normally. See willFillConstantPages / fillConstantPagesAndDecodeRest. + bool willFillConstantPages( + const ColumnChunk & column, const PrimitiveColumnInfo & column_info, + const RowGroup & row_group, const RowSubgroup & row_subgroup) const; + void fillConstantPagesAndDecodeRest( + ColumnChunk & column, const PrimitiveColumnInfo & column_info, + ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup); + /// Returns mutable column because some of the recursive calls require it, /// e.g. ColumnArray::create does assumeMutable() on the nested columns. /// Moves the column out of ColumnSubchunk-s, leaving nullptrs in ColumnSubchunk::column. From 9bba26073dd2982aadcf10455badf39826a35a7f Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 20:01:41 +0300 Subject: [PATCH 22/39] Parquet v3: skip prefetch of filled constant pages (mixed topology) Completes the mixed-topology fill (input_format_parquet_fill_constant_pages, default off): a subgroup that fills its single-value pages from the Column Index no longer prefetches them. determinePagesToPrefetch consults willFillConstantPages (the same deterministic predicate the decode path uses) and, for a fill subgroup, does not claim its is_const pages - so a page overlapped only by fill subgroups is released and never read, while a page also decoded normally by another subgroup is still fetched. Also adds a no-prewhere / no-row-level-filter gate to willFillConstantPages so rows_pass == rows_total holds identically at prefetch time and decode time, keeping the two decisions consistent. This turns the mixed-topology fill from a decode-CPU-only win into an I/O win too (fewer page reads), matching tier-2 at sub-subgroup granularity. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../parquet-v3-page-level-constant-column.md | 13 +++++++++---- .../Formats/Impl/Parquet/ReadManager.cpp | 2 +- .../Formats/Impl/Parquet/Reader.cpp | 19 ++++++++++++++++++- src/Processors/Formats/Impl/Parquet/Reader.h | 2 +- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/design/parquet-v3-page-level-constant-column.md b/docs/design/parquet-v3-page-level-constant-column.md index 36970a2effc7..bbc16efdb670 100644 --- a/docs/design/parquet-v3-page-level-constant-column.md +++ b/docs/design/parquet-v3-page-level-constant-column.md @@ -76,7 +76,12 @@ dictionary encoding stats) instead of an all-or-nothing switch. - Partial-page subgroup boundaries are fine: a partial overlap of a constant page still yields that value, as long as every overlapping page is constant with the shared value. -## Out of scope - -In-subgroup partial fill for constant runs *shorter* than a subgroup (skip only those pages' bytes, -yield a full column) — a later follow-up. +## Mixed-topology fill (Approach B) — implemented, default off + +`input_format_parquet_fill_constant_pages` handles constant runs *shorter* than / straddling a +subgroup: `fillConstantPagesAndDecodeRest` fills single-value pages from the Column Index and decodes +only the varying ones, and `determinePagesToPrefetch` skips prefetching the filled pages (a page +shared with a subgroup that decodes it normally is still fetched — `willFillConstantPages` is +deterministic so both paths agree). Gated to: no cast on the output value, no predicate on the +column, no prewhere/row-level filter, and no all-null pages in the subgroup. Experimental, off by +default; needs a build + correctness tests before it can be trusted. diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index bcf097d14a49..dd764c7ffa27 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -797,7 +797,7 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif /// pool and charges the compressed bytes to the ColumnDataPrefetch stage budget - /// separate from the decoded-output budget (ColumnData) - so many row groups can have /// their reads in flight (deep prefetch) while only a few are decoded at once. - reader.determinePagesToPrefetch(column, row_subgroup.columns.at(task.column_idx), row_subgroup, row_group, prefetches); + reader.determinePagesToPrefetch(column, row_subgroup.columns.at(task.column_idx), reader.primitive_columns.at(task.column_idx), row_subgroup, row_group, prefetches); /// Side note: would be nice to avoid reading the dictionary if all dictionary-encoded /// pages were filtered out (e.g. if it's a 100 MB column chunk with unique long strings, diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 168551d8327a..6c1d82f146c5 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1272,7 +1272,7 @@ void Reader::decodeOffsetIndex(ColumnChunk & column, const RowGroup & row_group) } } -void Reader::determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk & subchunk, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out) +void Reader::determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk & subchunk, const PrimitiveColumnInfo & column_info, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out) { chassert(row_subgroup.filter.rows_pass > 0); if (column.is_constant) @@ -1280,6 +1280,12 @@ void Reader::determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk if (column.offset_index.page_locations.empty()) return; // no offset index, can't prefetch individual pages + /// If this subgroup will fill its single-value pages from the Column Index instead of decoding + /// them (input_format_parquet_fill_constant_pages), don't prefetch those pages. Deterministic, so + /// the decode path (willFillConstantPages) makes the same decision. A const page shared with a + /// subgroup that decodes it normally is still claimed by that subgroup and thus fetched. + const bool fill_const_pages = willFillConstantPages(column, column_info, row_group, row_subgroup); + if (column.data_pages.empty()) { const auto & locations = column.offset_index.page_locations; @@ -1350,6 +1356,15 @@ void Reader::determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk if (subchunk.is_constant) passes_filter = false; + /// Mixed-topology fill: this subgroup fills its single-value pages instead of decoding them, + /// so it does not need those pages. (Varying pages in the same subgroup are still claimed.) + if (passes_filter && fill_const_pages) + { + const size_t gid = size_t(page.meta - column.offset_index.page_locations.data()); + if (gid < column.page_const_info.size() && column.page_const_info[gid].is_const) + passes_filter = false; + } + if (passes_filter) out.push_back(&page.prefetch); // this subgroup needs this page else if (page.end_row_idx > subgroup_end) @@ -1604,6 +1619,8 @@ bool Reader::willFillConstantPages( return false; // no Column Index info retained if (column_info.column_index_condition) return false; // a predicate on this column may have pruned pages; keep the standard path + if (format_filter_info && (format_filter_info->prewhere_info || format_filter_info->row_level_filter)) + return false; // a prewhere/row-level filter would make rows_pass < rows_total at decode time if (!constColumnMaterializationEligible(column_info)) return false; if (row_subgroup.filter.rows_pass != row_subgroup.filter.rows_total) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 7daab1049be9..8f0bfbd560e0 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -539,7 +539,7 @@ struct Reader /// Call after prewhere is done on row subgroup. Un-requests prefetch for fully filtered out pages, /// adds pages that need prefetch to `out`. Must be called in order. /// May assign dictionary_page_prefetch. - void determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk & subchunk, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out); + void determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk & subchunk, const PrimitiveColumnInfo & column_info, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out); /// Guess how much memory ColumnSubchunk::{column, arrays_offsets} will use, per row. double estimateColumnMemoryBytesPerRow(const ColumnChunk & column, const RowGroup & row_group, const PrimitiveColumnInfo & column_info) const; From ede6e2a41205abaf8d341d0df3b06e7b7bbac319 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 20:23:09 +0300 Subject: [PATCH 23/39] Parquet v3: allow string constants (drop min==max truncation guard) min_value == max_value provably means a single exact value for every physical type, including BYTE_ARRAY / FIXED_LEN_BYTE_ARRAY. Statistics and Column Index bounds are always valid (min_value <= every value <= max_value) and truncation only ever widens them, so a truncated value - or any page/chunk with two distinct values - yields min_value < max_value. Equality therefore requires a single value short enough to be stored exactly; the is_*_value_exact flags are implied and, for the Column Index (which has no per-page exact flag), never needed. Remove the BYTE_ARRAY/FIXED_LEN exclusion from tier 1 (detectConstantColumn) and the per-page recording in tier 2 (applyColumnIndex), so constant string columns and string constant page runs get the optimization too. Also fixes tier 1 for writers that omit is_*_value_exact (previously any such BYTE_ARRAY constant was skipped even when short and exact). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../parquet-v3-page-level-constant-column.md | 6 ++++- .../Formats/Impl/Parquet/Reader.cpp | 25 ++++++++----------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/docs/design/parquet-v3-page-level-constant-column.md b/docs/design/parquet-v3-page-level-constant-column.md index bbc16efdb670..352e31e6822a 100644 --- a/docs/design/parquet-v3-page-level-constant-column.md +++ b/docs/design/parquet-v3-page-level-constant-column.md @@ -69,7 +69,11 @@ dictionary encoding stats) instead of an all-or-nothing switch. ## Guardrails -- Fixed-width numeric/date/time only (truncation). +- All types, including `BYTE_ARRAY` / `FIXED_LEN_BYTE_ARRAY` strings. No truncation guard is needed + for the `min == max` case: statistics/Column-Index bounds are always valid + (`min <= every value <= max`) and truncation only widens them, so `min == max` proves a single + exact value (a truncated or multi-valued page yields `min < max`). This holds at both the chunk + level (tier 1) and per page (tier 2), so neither needs the `is_*_value_exact` flag. - Gate on the existing `input_format_parquet_use_constant_column_optimization` setting; the force-load above is additionally gated by `input_format_parquet_use_column_index_for_constant_columns`. diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 6c1d82f146c5..92137150f28c 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -985,13 +985,12 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & /// Tier 2 constant-column detection: retain per-page constant info from the Column Index for /// use by detectConstantSubchunk. Only when eligible and the whole chunk is not already - /// constant (tier 1). For truncatable physical types the Column Index has no per-page - /// exactness flag, so a per-page min == max is untrustworthy: record only `all_null` there. + /// constant (tier 1). No truncation guard is needed even though the Column Index has no + /// per-page exactness flag: bounds are always valid (min_value <= every value <= max_value) + /// and truncation only widens them, so a per-page min_value == max_value proves a single exact + /// value - for BYTE_ARRAY strings too (a truncated value would give min_value < max_value). const bool record_page_const = constColumnMaterializationEligible(column_info) && !column.is_constant; - const bool may_be_truncated = - column.meta->meta_data.type == parq::Type::BYTE_ARRAY - || column.meta->meta_data.type == parq::Type::FIXED_LEN_BYTE_ARRAY; if (record_page_const) column.page_const_info.assign(num_pages, ColumnChunk::PageConstInfo{}); @@ -1024,7 +1023,7 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & /// A page with no nulls whose decoded min == max holds a single value (post-cast /// output domain, like tier 1). Captured before adjustRangeFromIndexIfNeeded, which /// mutates the range for null/default handling. - if (record_page_const && !may_be_truncated && !can_be_null + if (record_page_const && !can_be_null && !range.left.isNull() && range.left == range.right) { column.page_const_info[page_idx].is_const = true; @@ -1513,15 +1512,11 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf if (stats.min_value != stats.max_value) return; - /// For BYTE_ARRAY / FIXED_LEN_BYTE_ARRAY the writer may store truncated min/max, which could make - /// two different values compare equal. Trust min == max only when the writer marked both exact. - /// Fixed-width numeric types are never truncated, so min == max is always exact for them. - const bool may_be_truncated = - meta_data.type == parq::Type::BYTE_ARRAY || meta_data.type == parq::Type::FIXED_LEN_BYTE_ARRAY; - if (may_be_truncated - && !(stats.__isset.is_min_value_exact && stats.is_min_value_exact - && stats.__isset.is_max_value_exact && stats.is_max_value_exact)) - return; + /// No truncation guard is needed for the min == max case, including BYTE_ARRAY / + /// FIXED_LEN_BYTE_ARRAY. Statistics bounds are always valid (min_value <= every value <= + /// max_value) and truncation only ever widens them, so a truncated value - or any chunk with two + /// distinct values - yields min_value < max_value. Hence min_value == max_value can only occur for + /// a single value short enough to be stored exactly, so `is_*_value_exact` is implied. Field value; column_info.decoder.decodeField(stats.min_value, /*is_max=*/ false, value); From 217c85aab31f21607b27c1b42e8d793efd79e338 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 20:33:57 +0300 Subject: [PATCH 24/39] Parquet v3: handle all-null pages in mixed-topology fill (lift gate 1) Previously a mixed-topology subgroup (input_format_parquet_fill_constant_pages) bailed to normal decode if any overlapping page was all-null. Now fill them too: an all-null page appends no non-null values and marks its rows null in the null map; the existing expand() + Nullable-wrap / null_as_default tail finalizes them exactly as the normal decode does (the column holds compact non-null values, the null map covers all rows). determinePagesToPrefetch also skips prefetching all-null pages, matching the fill. willFillConstantPages now accepts all-null pages as fillable, but still bails when an all-null page is present and the output can represent neither null (non-Nullable output) nor a default (no null_as_default) - the standard decode raises the usual not-null error there. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../parquet-v3-page-level-constant-column.md | 6 ++-- .../Formats/Impl/Parquet/Reader.cpp | 29 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/design/parquet-v3-page-level-constant-column.md b/docs/design/parquet-v3-page-level-constant-column.md index 352e31e6822a..bd8bccb52782 100644 --- a/docs/design/parquet-v3-page-level-constant-column.md +++ b/docs/design/parquet-v3-page-level-constant-column.md @@ -87,5 +87,7 @@ subgroup: `fillConstantPagesAndDecodeRest` fills single-value pages from the Col only the varying ones, and `determinePagesToPrefetch` skips prefetching the filled pages (a page shared with a subgroup that decodes it normally is still fetched — `willFillConstantPages` is deterministic so both paths agree). Gated to: no cast on the output value, no predicate on the -column, no prewhere/row-level filter, and no all-null pages in the subgroup. Experimental, off by -default; needs a build + correctness tests before it can be trusted. +column, no prewhere/row-level filter. All-null pages are handled (filled with nulls via the compact +values + null map + `expand` path, or the output default under `null_as_default`); a subgroup with +an all-null page only falls back when the output can represent neither null nor a default. +Experimental, off by default; needs a build + correctness tests before it can be trusted. diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 92137150f28c..a5f83cc0ea3d 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1360,7 +1360,8 @@ void Reader::determinePagesToPrefetch(ColumnChunk & column, const ColumnSubchunk if (passes_filter && fill_const_pages) { const size_t gid = size_t(page.meta - column.offset_index.page_locations.data()); - if (gid < column.page_const_info.size() && column.page_const_info[gid].is_const) + if (gid < column.page_const_info.size() + && (column.page_const_info[gid].is_const || column.page_const_info[gid].all_null)) passes_filter = false; } @@ -1632,12 +1633,15 @@ bool Reader::willFillConstantPages( if (num_pages == 0 || column.page_const_info.size() != num_pages) return false; - /// Require at least one single-value page and no all-null page overlapping the subgroup (all-null - /// pages are not handled by the fill; fall back to the standard decode for those subgroups). + /// Require at least one fillable page (single-value or all-null) overlapping the subgroup. An + /// all-null page is only representable when the output is Nullable or null_as_default substitutes + /// a default; otherwise fall back to the standard decode (which raises the usual not-null error). + const bool null_as_default = options.format.null_as_default && !column_info.output_nullable; + const bool nulls_representable = column_info.output_nullable || null_as_default; const size_t start = row_subgroup.start_row_idx; const size_t end = start + row_subgroup.filter.rows_total; const size_t rg_rows = size_t(row_group.meta->num_rows); - bool any_const = false; + bool any_fillable = false; for (size_t p = 0; p < num_pages; ++p) { const size_t p_start = size_t(pages[p].first_row_index); @@ -1645,12 +1649,12 @@ bool Reader::willFillConstantPages( if (p_end <= start || p_start >= end) continue; const auto & pci = column.page_const_info[p]; - if (pci.all_null) + if (pci.all_null && !nulls_representable) return false; - if (pci.is_const) - any_const = true; + if (pci.is_const || pci.all_null) + any_fillable = true; } - return any_const; + return any_fillable; } void Reader::fillConstantPagesAndDecodeRest( @@ -1691,6 +1695,15 @@ void Reader::fillConstantPagesAndDecodeRest( if (null_map_data) null_map_data->resize_fill(null_map_data->size() + count, 0); } + else if (pci.all_null) + { + /// All-null page: append no non-null values (the column holds the compact non-null + /// values); mark every row null. The caller's tail expand()s defaults into these + /// positions and applies the Nullable wrap / null_as_default handling, exactly as the + /// normal decode does. null_map exists here (an all-null page implies need_null_map). + chassert(null_map_data); + null_map_data->resize_fill(null_map_data->size() + count, 1); + } else { /// Varying page: decode [row, seg_end) via the normal machinery. skipToRowOrNextPage jumps From ac73c369c1e051c18cf2b2a0f9525ae91a2ac37c Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 20:43:28 +0300 Subject: [PATCH 25/39] Parquet v3: allow page-pruned columns in mixed fill (lift gate 3) Drop the column_index_condition gate from willFillConstantPages. A page-pruning predicate on the column (column_index_condition) is safe for the fill: subgroups are built only over contiguous surviving row ranges, so a pruned page never overlaps a subgroup and the fill never walks it; the prefetch-skip indexes page_const_info by each page global position (page.meta into page_locations), which is correct regardless of the data_pages subset. A column carrying an actual prewhere/row-level filter is still blocked by the no-prewhere gate. Gate 2 (output needs_cast) is intentionally left in place: whether decodeField yields the decoded or the output value domain is ambiguous from static reading (SchemaConverter/convertField vs the tier-1 is_constant branch), and guessing wrong is silent wrong data. It needs a build + a needs_cast test to resolve (and that test would also confirm tier-1/tier-2 on hint-cast columns). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- src/Processors/Formats/Impl/Parquet/Reader.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index a5f83cc0ea3d..58e8b115b036 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1613,10 +1613,11 @@ bool Reader::willFillConstantPages( return false; // whole chunk already constant (tier 1) if (column.page_const_info.empty()) return false; // no Column Index info retained - if (column_info.column_index_condition) - return false; // a predicate on this column may have pruned pages; keep the standard path if (format_filter_info && (format_filter_info->prewhere_info || format_filter_info->row_level_filter)) return false; // a prewhere/row-level filter would make rows_pass < rows_total at decode time + /// A page-pruning predicate on this column (column_index_condition without prewhere) is fine: + /// pruned pages fall outside every subgroup's contiguous surviving row range, so the fill never + /// walks them, and the prefetch-skip indexes page_const_info by each page's global position. if (!constColumnMaterializationEligible(column_info)) return false; if (row_subgroup.filter.rows_pass != row_subgroup.filter.rows_total) From 2cae9014c6c677c2b286f327606326bf4832ca6c Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 21:11:47 +0300 Subject: [PATCH 26/39] Parquet v3: filter-aware mixed fill under prewhere (lift gate 4) The mixed-topology fill now walks the rows that pass the filter (row_subgroup.filter) instead of the whole subgroup range, so it works under PREWHERE / row-level filters: it iterates passing ranges (like the standard row-range decode) and, within each, fills constant / all-null pages for their overlapping passing rows and decodes varying pages for the contiguous passing sub-range. It therefore produces exactly rows_pass values for any filter, and willFillConstantPages no longer depends on rows_pass - so the decision is identical at prefetch time and decode time and the prefetch-skip can never drop a page the decode needs. Removes the no-prewhere and rows_pass == rows_total gates from willFillConstantPages. The only remaining gate is needs_cast (build-gated; see the decodeField value-domain question). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../parquet-v3-page-level-constant-column.md | 15 ++- .../Formats/Impl/Parquet/Reader.cpp | 112 ++++++++++-------- 2 files changed, 75 insertions(+), 52 deletions(-) diff --git a/docs/design/parquet-v3-page-level-constant-column.md b/docs/design/parquet-v3-page-level-constant-column.md index bd8bccb52782..83887eaa029c 100644 --- a/docs/design/parquet-v3-page-level-constant-column.md +++ b/docs/design/parquet-v3-page-level-constant-column.md @@ -86,8 +86,13 @@ dictionary encoding stats) instead of an all-or-nothing switch. subgroup: `fillConstantPagesAndDecodeRest` fills single-value pages from the Column Index and decodes only the varying ones, and `determinePagesToPrefetch` skips prefetching the filled pages (a page shared with a subgroup that decodes it normally is still fetched — `willFillConstantPages` is -deterministic so both paths agree). Gated to: no cast on the output value, no predicate on the -column, no prewhere/row-level filter. All-null pages are handled (filled with nulls via the compact -values + null map + `expand` path, or the output default under `null_as_default`); a subgroup with -an all-null page only falls back when the output can represent neither null nor a default. -Experimental, off by default; needs a build + correctness tests before it can be trusted. +deterministic so both paths agree). The fill walks the rows that pass the filter (`row_subgroup.filter`), so it works under PREWHERE / +row-level filters and page-pruning predicates too - it produces exactly `rows_pass` values for any +filter, and because the decision no longer depends on `rows_pass` it is identical at prefetch time +and decode time (so the prefetch-skip can never drop a page the decode needs). All-null pages are +filled (nulls via the compact values + null map + `expand` path, or the output default under +`null_as_default`); a subgroup with an all-null page falls back only when the output can represent +neither null nor a default. The one remaining gate is `needs_cast`: the fill writes the Column Index +value into the `decoded_type` column, valid only when no post-decode cast applies (resolving whether +`decodeField` yields the decoded or the output value domain is build-gated). Experimental, off by +default; needs a build + correctness tests before it can be trusted. diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 58e8b115b036..1d8a1d834b45 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1613,15 +1613,12 @@ bool Reader::willFillConstantPages( return false; // whole chunk already constant (tier 1) if (column.page_const_info.empty()) return false; // no Column Index info retained - if (format_filter_info && (format_filter_info->prewhere_info || format_filter_info->row_level_filter)) - return false; // a prewhere/row-level filter would make rows_pass < rows_total at decode time - /// A page-pruning predicate on this column (column_index_condition without prewhere) is fine: - /// pruned pages fall outside every subgroup's contiguous surviving row range, so the fill never - /// walks them, and the prefetch-skip indexes page_const_info by each page's global position. + /// PREWHERE / row-level filters and page-pruning predicates are all handled: the fill walks only + /// the rows that pass the filter (row_subgroup.filter), so it produces exactly rows_pass values + /// for any filter, and pruned pages fall outside the subgroup's surviving ranges. The decision + /// here is independent of rows_pass, so it is identical at prefetch time and decode time. if (!constColumnMaterializationEligible(column_info)) return false; - if (row_subgroup.filter.rows_pass != row_subgroup.filter.rows_total) - return false; // some rows filtered out in this subgroup; avoid filter/range intersection /// We fill the decoded_type column with the output-domain value from the Column Index, which is /// only valid when no post-decode cast is applied (decoded_type == output value type). @@ -1663,60 +1660,81 @@ void Reader::fillConstantPagesAndDecodeRest( ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup) { const size_t start = row_subgroup.start_row_idx; - const size_t end = start + row_subgroup.filter.rows_total; + const size_t rows_total = row_subgroup.filter.rows_total; const auto & pages = column.offset_index.page_locations; const size_t num_pages = pages.size(); const size_t rg_rows = size_t(row_group.meta->num_rows); + const auto & filter = row_subgroup.filter.filter; ColumnUInt8::Container * null_map_data = subchunk.null_map ? &assert_cast(*subchunk.null_map).getData() : nullptr; - /// Position at the first page overlapping `start`. - size_t p = 0; - while (p < num_pages && (p + 1 < num_pages ? size_t(pages[p + 1].first_row_index) : rg_rows) <= start) - ++p; + auto page_end_of = [&](size_t pg) { return (pg + 1 < num_pages) ? size_t(pages[pg + 1].first_row_index) : rg_rows; }; - size_t row = start; - while (row < end) + /// Iterate the ranges of rows that pass the filter (like the standard row-range decode), and walk + /// the pages overlapping each range. Within a passing range every row is output, so a constant / + /// all-null page contributes exactly its overlapping row count, and a varying page is decoded for + /// that contiguous sub-range. This produces the compact (non-null) values + null map that the + /// caller's expand() / Nullable-wrap / null_as_default tail finalizes - matching the normal + /// decode for any rows_pass, so no rows_pass == rows_total restriction is needed. + size_t page_cursor = 0; // forward cursor over page_locations + size_t row_subidx = 0; // offset within [0, rows_total) + while (true) { - chassert(p < num_pages); - const size_t p_end = (p + 1 < num_pages) ? size_t(pages[p + 1].first_row_index) : rg_rows; - const size_t seg_end = std::min(end, p_end); - chassert(seg_end > row); - const size_t count = seg_end - row; - const auto & pci = column.page_const_info[p]; - - if (pci.is_const) - { - /// Fill the single value without reading the page. Value is in the output value domain, - /// which equals decoded_type here (willFillConstantPages required no cast). A single-value - /// page has no nulls, so any null map gets zeros for these rows. - subchunk.column->insertMany(pci.value, count); - if (null_map_data) - null_map_data->resize_fill(null_map_data->size() + count, 0); - } - else if (pci.all_null) + size_t num_rows = rows_total - row_subidx; + if (!filter.empty()) { - /// All-null page: append no non-null values (the column holds the compact non-null - /// values); mark every row null. The caller's tail expand()s defaults into these - /// positions and applies the Nullable wrap / null_as_default handling, exactly as the - /// normal decode does. null_map exists here (an all-null page implies need_null_map). - chassert(null_map_data); - null_map_data->resize_fill(null_map_data->size() + count, 1); + while (row_subidx < rows_total && !filter[row_subidx]) + ++row_subidx; + num_rows = 0; + while (row_subidx + num_rows < rows_total && filter[row_subidx + num_rows]) + ++num_rows; } - else + if (!num_rows) + break; + const size_t range_start = start + row_subidx; + const size_t range_end = range_start + num_rows; + row_subidx += num_rows; + + size_t row = range_start; + while (row < range_end) { - /// Varying page: decode [row, seg_end) via the normal machinery. skipToRowOrNextPage jumps - /// to this page (via the offset index), stepping over any filled const pages without - /// loading them; readRowsInPage appends the decoded values (and null-map entries). - skipToRowOrNextPage(row, column, column_info); - readRowsInPage(seg_end, subchunk, column, column_info, &row_subgroup); - } + while (page_cursor < num_pages && page_end_of(page_cursor) <= row) + ++page_cursor; + chassert(page_cursor < num_pages); + const size_t seg_end = std::min(range_end, page_end_of(page_cursor)); + chassert(seg_end > row); + const size_t count = seg_end - row; + const auto & pci = column.page_const_info[page_cursor]; + + if (pci.is_const) + { + /// Fill the single value without reading the page (value is in decoded_type domain, + /// which equals the output value domain - willFillConstantPages required no cast). A + /// single-value page has no nulls, so any null map gets zeros for these rows. + subchunk.column->insertMany(pci.value, count); + if (null_map_data) + null_map_data->resize_fill(null_map_data->size() + count, 0); + } + else if (pci.all_null) + { + /// All-null page: no non-null values; mark these rows null (expand() fills defaults + /// later). null_map exists here (an all-null page implies need_null_map). + chassert(null_map_data); + null_map_data->resize_fill(null_map_data->size() + count, 1); + } + else + { + /// Varying page: decode the contiguous passing sub-range [row, seg_end). All rows in + /// it pass, so no per-row filter is applied here. skipToRowOrNextPage jumps to the page + /// via the offset index, stepping over filled const/all-null pages without loading them. + skipToRowOrNextPage(row, column, column_info); + readRowsInPage(seg_end, subchunk, column, column_info, /*row_subgroup=*/ nullptr); + } - row = seg_end; - if (row == p_end) - ++p; + row = seg_end; + } } } From f78ffa2c179ec7c0695c2d76881c0c48bac82baf Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 21:53:27 +0300 Subject: [PATCH 27/39] Parquet v3: cast the materialized constant (future-proof needs_cast) formOutputColumn now builds the single-value constant in input_type and applies the same castColumn the per-row decode uses when needs_cast is set, instead of inserting the value straight into an output_type column and skipping the cast. Today this is a no-op: the optimization only fires when the stats decoder needs no value-transforming conversion (SchemaConverter gates allow_stats on that), so input_type == output_type for every constant column. The change keeps the constant path correct if allow_stats is ever generalized to allow transforming casts (e.g. decimal rescale) - the constant would then go through the same cast as the normal decode. Casting a ColumnConst is O(1) (one value) and preserves const-ness, so no cost. The all-null constant is synthesized in the output domain (Null / output default) and is not cast. The mixed fill already casts via the normal path, so it is future-safe too. The allow_stats / SchemaConverter logic is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../parquet-v3-page-level-constant-column.md | 12 +++++ .../Formats/Impl/Parquet/Reader.cpp | 47 +++++++++++++------ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/docs/design/parquet-v3-page-level-constant-column.md b/docs/design/parquet-v3-page-level-constant-column.md index 83887eaa029c..80bb8c2de78d 100644 --- a/docs/design/parquet-v3-page-level-constant-column.md +++ b/docs/design/parquet-v3-page-level-constant-column.md @@ -67,6 +67,18 @@ constant info but skips predicate pruning when the column has no condition. A fu could gate this on footer signals (row-group `sorting_columns`, low compressed-bytes-per-value, dictionary encoding stats) instead of an all-or-nothing switch. +## Cast safety (future-proofing) + +The optimization only fires when the stats decoder needs no value-transforming conversion +(`SchemaConverter` sets `allow_stats` accordingly), so today `input_type == output_type` for every +constant column and no cast is applied. To keep the constant path correct if `allow_stats` is ever +generalized to allow transforming casts, `formOutputColumn` builds the single-value constant in +`input_type` and runs the same `castColumn` the per-row decode uses when `needs_cast` is set - a +no-op today, O(1) on the `ColumnConst`, and it preserves const-ness. The all-null constant is +synthesized directly in the output domain (Null / output default), so it is not cast. The mixed fill +already goes through the normal `formOutputColumn` cast, so it is future-safe too. This does not +touch the `allow_stats` decision itself. + ## Guardrails - All types, including `BYTE_ARRAY` / `FIXED_LEN_BYTE_ARRAY` strings. No truncation guard is needed diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 1d8a1d834b45..3d7a7a64b423 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -2634,20 +2634,39 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out /// so materialize it as a ColumnConst rather than an expanded column. This is O(1) instead /// of O(rows), and the const-ness propagates downstream: a PREWHERE/WHERE predicate /// computes its result from the value without expanding the stored column, and GROUP BY / - /// aggregation over this column get a const key. The value is already in the output - /// (post-cast) domain, so we skip the decoded_type column and the castColumn below. - MutableColumnPtr single_value = output_info.output_type->createColumn(); - single_value->insert(subchunk.constant_value); - - /// An all-null chunk must record every row in block_missing_values, matching the normal - /// decode path (which records nulls from the null map); needed for - /// input_format_null_as_default. The single-value case has no nulls, so records nothing. - if (subchunk.is_all_null - && output_info.idx_in_output_block.has_value() - && *output_info.idx_in_output_block < row_subgroup.block_missing_values.getNumColumns()) - row_subgroup.block_missing_values.setBits(*output_info.idx_in_output_block, num_rows); - - return ColumnConst::create(std::move(single_value), num_rows); + /// aggregation over this column get a const key. + ColumnPtr result; + if (subchunk.is_all_null) + { + /// constant_value was synthesized directly in the output domain (Null for a Nullable + /// output, or the output default under null_as_default), so no cast applies. + MutableColumnPtr single_value = output_info.output_type->createColumn(); + single_value->insert(subchunk.constant_value); + result = ColumnConst::create(std::move(single_value), num_rows); + + /// An all-null chunk must record every row in block_missing_values, matching the + /// normal decode path (which records nulls from the null map); needed for + /// input_format_null_as_default. + if (output_info.idx_in_output_block.has_value() + && *output_info.idx_in_output_block < row_subgroup.block_missing_values.getNumColumns()) + row_subgroup.block_missing_values.setBits(*output_info.idx_in_output_block, num_rows); + } + else + { + /// The value came from decodeField, i.e. the decoded/input domain. Build the const in + /// input_type and apply the same castColumn the per-row decode uses when the output + /// type differs. Today the optimization only fires when the stats decoder does not + /// need a value-transforming conversion (input_type == output_type), so this cast is a + /// no-op; it is here so the constant path stays correct if that ever changes. Casting a + /// ColumnConst is O(1) (one value) and preserves const-ness. + MutableColumnPtr single_value = output_info.input_type->createColumn(); + single_value->insert(subchunk.constant_value); + result = ColumnConst::create(std::move(single_value), num_rows); + if (output_info.needs_cast) + result = castColumn({result, output_info.input_type, output_info.name}, output_info.output_type); + } + + return IColumn::mutate(std::move(result)); } res = std::move(subchunk.column); From 56fc24d9fd6530c0f7a9cc268aa809f690669491 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 22:19:05 +0300 Subject: [PATCH 28/39] Parquet v3: footer-size hint for the metadata tail read readFileMetaData now takes a footer size hint (bytes); when set it sizes the initial tail read instead of the fixed 64 KiB speculative read. ReadOptions carries footer_metadata_size_hint (default 0 = current behavior), threaded through every readFileMetaData caller. Adds estimateParquetFooterSize(num_columns, num_row_groups, bounds_bytes), a pure clamped estimator a data lake can feed from per-file stats. Only affects read count, never correctness: an undershoot falls back to the existing second read, an overshoot reads a slightly larger (clamped) tail. No behavior change until a caller supplies a non-zero hint - the Iceberg wiring that computes it from the manifest (column count, row groups from split_offsets, summed lower/upper_bounds sizes) is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../Formats/Impl/Parquet/ReadCommon.h | 35 +++++++++++++++++++ .../Formats/Impl/Parquet/ReadManager.cpp | 2 +- .../Formats/Impl/Parquet/Reader.cpp | 10 +++--- src/Processors/Formats/Impl/Parquet/Reader.h | 4 ++- .../Impl/ParquetV3BlockInputFormat.cpp | 6 ++-- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.h b/src/Processors/Formats/Impl/Parquet/ReadCommon.h index 4d1652d07dc8..9c39e474c47e 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.h +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.h @@ -3,6 +3,8 @@ #include #include +#include + namespace DB { struct FormatParserSharedResources; @@ -36,8 +38,41 @@ struct ReadOptions /// probability becomes very high. E.g. if bloom filter has 1% false positive probability, /// searching for 100 elements would have 63% false positive probability. size_t bloom_filter_max_set_size = 100; + + /// Hint (in bytes) for how much of the file tail to read when fetching the parquet footer + /// (FileMetaData). 0 = unknown -> use the default speculative 64 KiB read. A data lake that + /// already knows per-file stats (e.g. Iceberg: column count, row-group count from split_offsets, + /// and per-column bound sizes) can set this via estimateParquetFooterSize so the footer is + /// captured in a single read for wide / many-row-group files. Only affects read count, never + /// correctness: an undershoot falls back to a second read, an overshoot reads a slightly larger + /// (already-clamped) tail. + size_t footer_metadata_size_hint = 0; }; +/// Estimate the serialized size of a parquet FileMetaData footer, to size the initial tail read. +/// - num_columns: number of leaf columns, +/// - num_row_groups: number of row groups, +/// - bounds_bytes: total bytes of the per-column min+max bounds for one file (e.g. summed from an +/// Iceberg manifest's lower_bounds + upper_bounds); these repeat per row group in the footer. +/// The result is clamped to a sane speculative-read range, so it is always safe to use directly as +/// ReadOptions::footer_metadata_size_hint. +inline size_t estimateParquetFooterSize(size_t num_columns, size_t num_row_groups, size_t bounds_bytes) +{ + /// Rough per-structure sizes of thrift-compact FileMetaData (see the constants' rationale in the + /// design notes). Overestimating only costs a marginally larger tail read; underestimating just + /// triggers the reader's existing second-read fallback. + constexpr size_t fixed_overhead = 4096; /// schema + file-level key/value metadata + constexpr size_t per_row_group = 64; /// RowGroupMetaData wrapper + constexpr size_t per_column_chunk = 112; /// ColumnMetaData fixed fields (offsets, sizes, ...) + constexpr size_t floor_size = 64ul << 10; /// never smaller than the default speculative read + constexpr size_t cap_size = 16ul << 20; /// don't speculatively read an enormous tail + + size_t est = fixed_overhead + + num_row_groups * (per_row_group + num_columns * per_column_chunk + bounds_bytes); + est += est / 3; /// ~1.3x safety for column names and Iceberg-vs-parquet truncation-length skew + return std::clamp(est, floor_size, cap_size); +} + struct SharedResourcesExt { size_t total_memory_low_watermark = 0; diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index dd764c7ffa27..ae61ef8084a4 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -53,7 +53,7 @@ void ReadManager::init(FormatParserSharedResourcesPtr parser_shared_resources_, parser_shared_resources = parser_shared_resources_; if (reader.file_metadata.schema.empty()) - reader.file_metadata = Reader::readFileMetaData(reader.prefetcher); + reader.file_metadata = Reader::readFileMetaData(reader.prefetcher, reader.options.footer_metadata_size_hint); if (buckets_to_read_) { diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 3d7a7a64b423..1892724b298f 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -192,7 +192,7 @@ void Reader::init(const ReadOptions & options_, const Block & sample_block_, For format_filter_info = format_filter_info_; } -parq::FileMetaData Reader::readFileMetaData(Prefetcher & prefetcher) +parq::FileMetaData Reader::readFileMetaData(Prefetcher & prefetcher, size_t footer_size_hint) { /// Parquet file ends with: /// * serialized FileMetaData struct, @@ -203,9 +203,11 @@ parq::FileMetaData Reader::readFileMetaData(Prefetcher & prefetcher) if (file_size <= 8) throw Exception(ErrorCodes::INCORRECT_DATA, "Parquet file too short: {} bytes", file_size); - /// Read the last 64 KiB in hopes that FileMetaData is smaller than that. - /// This is usually enough for files smaller than a few hundred MB. - size_t initial_read_size = std::min(file_size, 64ul << 10); + /// Read a tail chunk in hopes that FileMetaData is smaller than that. Default 64 KiB is usually + /// enough for files smaller than a few hundred MB; a caller that knows more (e.g. Iceberg, via + /// footer_size_hint) can size it so wide / many-row-group files are captured in one read. If the + /// footer turns out larger, the code below reads the exact remainder (one extra read). + size_t initial_read_size = std::min(file_size, footer_size_hint ? footer_size_hint : (64ul << 10)); PODArray buf(initial_read_size); prefetcher.readSync(buf.data(), initial_read_size, file_size - initial_read_size); diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 8f0bfbd560e0..09cb32436e77 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -520,7 +520,9 @@ struct Reader void init(const ReadOptions & options_, const Block & sample_block_, FormatFilterInfoPtr format_filter_info_); - static parq::FileMetaData readFileMetaData(Prefetcher & prefetcher); + /// footer_size_hint: optional hint (bytes) for the initial tail read; 0 = default speculative + /// read (see ReadOptions::footer_metadata_size_hint / estimateParquetFooterSize). + static parq::FileMetaData readFileMetaData(Prefetcher & prefetcher, size_t footer_size_hint = 0); void prefilterAndInitRowGroups(const std::optional> & row_groups_to_read); void preparePrewhere(); diff --git a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp index edbf421ccbeb..009918aa08a6 100644 --- a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp +++ b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp @@ -112,11 +112,11 @@ parquet::format::FileMetaData ParquetV3BlockInputFormat::getFileMetadata(Parquet String etag = object_with_metadata->metadata->etag; ParquetMetadataCacheKey cache_key = ParquetMetadataCache::createKey(file_name, etag); return metadata_cache->getOrSetMetadata( - cache_key, [&]() { return Parquet::Reader::readFileMetaData(prefetcher); }); + cache_key, [&]() { return Parquet::Reader::readFileMetaData(prefetcher, read_options.footer_metadata_size_hint); }); } else { - return Parquet::Reader::readFileMetaData(prefetcher); + return Parquet::Reader::readFileMetaData(prefetcher, read_options.footer_metadata_size_hint); } } @@ -214,7 +214,7 @@ void NativeParquetSchemaReader::initializeIfNeeded() return; Parquet::Prefetcher prefetcher; prefetcher.init(&in, read_options, /*parser_shared_resources_=*/ nullptr); - file_metadata = Parquet::Reader::readFileMetaData(prefetcher); + file_metadata = Parquet::Reader::readFileMetaData(prefetcher, read_options.footer_metadata_size_hint); initialized = true; } From e3ab7738e81bc90bafcec7a55c7592b1847cf377 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 22:29:36 +0300 Subject: [PATCH 29/39] Iceberg: size the parquet footer read from manifest stats Wire the footer-size hint end to end so an Iceberg data file fetches its parquet FileMetaData in a single tail read instead of the blind 64 KiB (then a second read for wide / many-row-group files). - RelativePathWithMetadata gains an optional footer_size_hint that rides through the existing object_info -> object_with_metadata copy into the format creator. - IcebergDataObjectInfo computes it in its manifest ctor via Parquet::estimateParquetFooterSize(num_columns, num_row_groups, bounds_bytes): num_columns from the manifest column stats, bounds_bytes summed from the per-column value_bounds, and num_row_groups estimated from record_count (split_offsets are not parsed). Parquet files only; guarded by USE_PARQUET. - ParquetV3BlockInputFormat reads object_with_metadata->footer_size_hint into read_options.footer_metadata_size_hint in its constructor. Only affects read count, never correctness: an underestimate falls back to the existing second read, an overestimate reads a slightly larger clamped tail. Also avoids the S3 HEAD indirectly since file_size_in_bytes already flows via getFileSizeHint. No change for non-Iceberg reads (hint stays unset). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: UnamedRus --- .../ObjectStorages/IObjectStorage.h | 5 +++ .../Impl/ParquetV3BlockInputFormat.cpp | 6 +++ .../Iceberg/IcebergDataObjectInfo.cpp | 37 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h index 41b16b95e50b..7f6395152ff0 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h @@ -120,6 +120,11 @@ struct RelativePathWithMetadata /// Object metadata: size, modification time, etc. std::optional metadata; + /// Optional per-file hint (bytes) for how much of the file tail to read to get the format's + /// footer/metadata. Set by data lakes that already know per-file stats (e.g. Iceberg from the + /// manifest) and consumed by the format reader (see Parquet ReadOptions::footer_metadata_size_hint). + std::optional footer_size_hint; + RelativePathWithMetadata() = default; explicit RelativePathWithMetadata(String relative_path_, std::optional metadata_ = std::nullopt) diff --git a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp index 009918aa08a6..81355e1e41d3 100644 --- a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp +++ b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp @@ -58,6 +58,12 @@ ParquetV3BlockInputFormat::ParquetV3BlockInputFormat( read_options.min_bytes_for_seek = min_bytes_for_seek; read_options.bytes_per_read_task = min_bytes_for_seek * 4; + /// A data lake (e.g. Iceberg) may have precomputed how much of the file tail to read for the + /// footer from its per-file stats; use it to size the initial metadata read (see + /// estimateParquetFooterSize). 0/absent keeps the default speculative read. + if (object_with_metadata && object_with_metadata->footer_size_hint) + read_options.footer_metadata_size_hint = *object_with_metadata->footer_size_hint; + if (!format_filter_info) format_filter_info = std::make_shared(); } diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp index 4e161ca8c863..6598ad91b234 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp @@ -16,6 +16,11 @@ #include #include +#if USE_PARQUET +#include +#include +#endif + namespace DB::ErrorCodes { extern const int NOT_IMPLEMENTED; @@ -52,6 +57,16 @@ String computePartitionId(const Row & partition_key_value) #if USE_AVRO +#if USE_PARQUET +/// Rough byte size of a decoded manifest bound value, used only to size the parquet footer read. +static size_t estimateBoundFieldBytes(const Field & f) +{ + if (f.getType() == Field::Types::String) + return f.safeGet().size(); + return 16; /// conservative for numeric / decimal / date / uuid bounds +} +#endif + IcebergDataObjectInfo::IcebergDataObjectInfo( Iceberg::ProcessedManifestFileEntryPtr data_manifest_file_entry_, const String & resolved_storage_path_, Int32 schema_id_relevant_to_iterator_) : ObjectInfo(RelativePathWithMetadata(resolved_storage_path_)) @@ -68,6 +83,28 @@ IcebergDataObjectInfo::IcebergDataObjectInfo( data_manifest_file_entry_->parsed_entry->record_count, data_manifest_file_entry_->parsed_entry->file_size_in_bytes} { +#if USE_PARQUET + /// Precompute a footer-size hint from the manifest stats so the parquet reader can fetch the + /// FileMetaData in a single tail read (see Parquet::estimateParquetFooterSize). Parquet only; + /// the hint is ignored by other formats. row-group count is estimated from record_count because + /// split_offsets are not parsed. Only affects read count, never correctness. + const auto & entry = *data_manifest_file_entry_->parsed_entry; + if (entry.file_format == "PARQUET") + { + size_t num_columns = std::max(entry.columns_infos.size(), entry.value_bounds.size()); + if (num_columns > 0) + { + constexpr size_t rows_per_row_group_guess = 1'000'000; + size_t rows = size_t(std::max(entry.record_count, 0)); + size_t num_row_groups = std::max(1, (rows + rows_per_row_group_guess - 1) / rows_per_row_group_guess); + size_t bounds_bytes = 0; + for (const auto & [field_id, bounds] : entry.value_bounds) + bounds_bytes += estimateBoundFieldBytes(bounds.first) + estimateBoundFieldBytes(bounds.second); + relative_path_with_metadata.footer_size_hint + = Parquet::estimateParquetFooterSize(num_columns, num_row_groups, bounds_bytes); + } + } +#endif } IcebergDataObjectInfo::IcebergDataObjectInfo(const RelativePathWithMetadata & path_) From 5c46e9a42f0d0268f7175b83a9c0341c1686b2b3 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Fri, 7 Aug 2026 23:12:04 +0300 Subject: [PATCH 30/39] Parquet v3: fix tier-2 constant detection for non-nullable columns Tier-2 per-subgroup constant detection (`detectConstantSubchunk`, gated by `input_format_parquet_use_column_index_for_constant_columns`) never fired for required (non-nullable) columns. `applyColumnIndex` computed `can_be_null` purely from the Column Index `null_counts`: `!__isset.null_counts || null_counts[page] != 0`. ClickHouse's own Parquet writer emits `null_counts` only for nullable columns (`Write.cpp`, `has_null_count = max_def == 1 && max_rep == 0`), so for a required column `null_counts` is absent and `can_be_null` was spuriously `true` on every page. That disabled the per-page `is_const` branch, so `page_const_info` was never populated and tier-2 detection never triggered - exactly for the common case of a non-nullable constant column. A required column (max def level 0) can never contain nulls regardless of whether the Column Index carries `null_counts`, so fold the schema `nullable` flag into `can_be_null`. `adjustRangeFromIndexIfNeeded` already gates its own use on `nullable && can_be_null`, so required-column predicate-pruning behavior is unchanged. Verified on a real build (sha 60841f9decd): an otherwise identical file with a nullable `run` column yields `ParquetConstantColumnSubchunks = 22` while the non-nullable variant yielded 0; this is the same null_count-omission pattern already fixed for tier-1 whole-chunk detection. Signed-off-by: UnamedRus --- src/Processors/Formats/Impl/Parquet/Reader.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 1892724b298f..438c4cd253a5 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1004,7 +1004,12 @@ void Reader::applyColumnIndex(ColumnChunk & column, const PrimitiveColumnInfo & range = Range::createWholeUniverse(); bool always_null = !column_index.null_pages.empty() && column_index.null_pages[page_idx]; - bool can_be_null = !column_index.__isset.null_counts || column_index.null_counts[page_idx] != 0; + /// A required column (max def level 0) can never contain nulls, regardless of whether the + /// Column Index carries null_counts. This matters for tier-2 constant detection below: + /// ClickHouse's own writer only emits null_counts for nullable columns (see Write.cpp, + /// has_null_count = max_def == 1), so without the `nullable` guard can_be_null would be + /// spuriously true for every required column and the is_const branch would never fire. + bool can_be_null = nullable && (!column_index.__isset.null_counts || column_index.null_counts[page_idx] != 0); if (record_page_const) column.page_const_info[page_idx].all_null = always_null; From 6fc1e8ac620f7f77cdaf2b231baf4a6cbb2f7eb0 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 00:36:21 +0300 Subject: [PATCH 31/39] Parquet v3: serve Column/Offset Index from the retained footer tail The Column Index and Offset Index live just below the `FileMetaData` footer, so their bytes are already inside the tail chunk that `readFileMetaData` reads to parse the footer. That read went into a throwaway local buffer the `Prefetcher` did not remember, so the later `registerRange` for each index issued a fresh read - a redundant object read (a separate `S3GetObject` on remote storage) for bytes already in memory. Retain that tail in the `Prefetcher` and serve any range fully contained in it without a read, reusing the existing zero-copy `cached_region` path: a range inside the retained window is attached to a one-off `Task` pre-marked `Done` whose `cached_region` points into the retained buffer, so `getRangeData` returns the span directly. `retained_tail` is a `Prefetcher` member that outlives all handles, so the region needs no keep-alive. The retain is a no-op for `EntireFileIsInMemory` (nothing to save) and falls through to the normal read path for any range not fully contained, so it is strictly additive. It is single-threaded (before any prefetching) and read-only afterwards, so it needs no locking. New ProfileEvent `ParquetPrefetcherServedFromRetainedTail` counts the reads avoided. This works best together with `footer_metadata_size_hint`, which widens the initial tail read to span the whole footer + index region of wide / many-row-group files: the hint sizes the read, this change serves the index from it. Together the footer and its index cost one read instead of two or three on remote object storage. Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 1 + .../Formats/Impl/Parquet/Prefetcher.cpp | 44 +++++++++++++++++++ .../Formats/Impl/Parquet/Prefetcher.h | 15 +++++++ .../Formats/Impl/Parquet/Reader.cpp | 7 +++ 4 files changed, 67 insertions(+) diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index cd833a9bb1ad..36fab9cf5469 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1449,6 +1449,7 @@ The server successfully detected this situation and will download merged part fr M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadSeekAndRead, "The total number of reads with ReadMode::SeekAndRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadEntireFile, "The total number of read with ReadMode::EntireFileIsInMemory by DB::Parquet::Prefetcher", ValueType::Number) \ + M(ParquetPrefetcherServedFromRetainedTail, "The number of ranges (e.g. Column/Offset Index) served from the retained footer tail by DB::Parquet::Prefetcher without issuing a read", ValueType::Number) \ M(ParquetRowsFilterExpression, "The total number of rows that were passed through filter", ValueType::Number) \ M(ParquetColumnsFilterExpression, "The total number of columns that were passed through filter", ValueType::Number) \ M(FilterTransformPassedRows, "Number of rows that passed the filter in the query", ValueType::Number) \ diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index d4c617e77d88..02d68f553ea5 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -22,6 +22,7 @@ namespace ProfileEvents extern const Event ParquetPrefetcherReadRandomRead; extern const Event ParquetPrefetcherReadSeekAndRead; extern const Event ParquetPrefetcherReadEntireFile; + extern const Event ParquetPrefetcherServedFromRetainedTail; } namespace DB::Parquet @@ -136,6 +137,19 @@ void Prefetcher::readSync(char * to, size_t n, size_t offset) throw Exception(ErrorCodes::INCORRECT_DATA, "Unexpected eof: offset {}, length {}, bytes read {}, expected file size {}", offset, n, nread, file_size); } +void Prefetcher::retainTail(const char * data, size_t length, size_t file_offset) +{ + /// Nothing to save when the whole file is already in memory (getRangeData copies from + /// entire_file, no read is issued). Only the read-issuing modes benefit. + if (read_mode == ReadMode::EntireFileIsInMemory || length == 0) + return; + chassert(!ranges_finalized.load(std::memory_order_relaxed)); + chassert(file_offset + length <= file_size); + retained_tail.assign(data, data + length); + retained_tail_start = file_offset; + retained_tail_end = file_offset + length; +} + PrefetchHandle Prefetcher::registerRange(size_t offset, size_t length, bool likely_to_be_used) { chassert(!ranges_finalized.load(std::memory_order_relaxed)); @@ -312,6 +326,36 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, end_offset = ranges[range_idx].end; } + /// If this range is fully contained in the retained footer tail, serve it directly from that + /// in-memory copy - no read, no coalescing. Build a one-off Task already in the Done state whose + /// cached_region points into `retained_tail`; getRangeData's zero-copy path then returns the + /// span. This eliminates the redundant Column/Offset Index read (their bytes were already + /// fetched for the footer). `retained_tail` outlives all handles (it is a Prefetcher member), so + /// the region needs no keep-alive handle. + if (retained_tail_end > retained_tail_start + && start_offset >= retained_tail_start && end_offset <= retained_tail_end) + { + Task & task = tasks.emplace_back(); + task.offset = start_offset; + task.length = end_offset - start_offset; + task.memory_amplification = 1; + task.refcount.store(1); + task.state.store(Task::State::Done); + task.cached_region = Task::CachedReadRegion{ + .handle = {}, + .data = retained_tail.data() + (start_offset - retained_tail_start), + .size = end_offset - start_offset, + .file_offset = start_offset}; + + initial_req->task = &task; + initial_req->task_offset = 0; + RequestState::State s = RequestState::State::HasRange; + bool ok = initial_req->state.compare_exchange_strong(s, RequestState::State::HasTask); + chassert(ok); // we hold a PrefetchHandle, so it cannot be Cancelled here + ProfileEvents::increment(ProfileEvents::ParquetPrefetcherServedFromRetainedTail); + return; // lock released by unique_lock destructor; task is already Done, nothing to schedule + } + /// Try to extend the task's range in both directions to cover more request ranges, as long /// as gaps between them are shorter than min_bytes_for_seek. diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index c9b5f5c02f05..d9f9813ad324 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -63,6 +63,14 @@ class Prefetcher /// Pass-through read from the underlying ReadBuffer. void readSync(char * to, size_t n, size_t offset); + /// Retain a tail chunk of the file (the bytes already read to parse the footer) so that any + /// range subsequently registered and fully contained in it - notably the Column Index and + /// Offset Index, which live just below the FileMetaData footer - is served from this in-memory + /// copy instead of issuing another read. No-op for EntireFileIsInMemory (nothing to save) and + /// when the chunk is empty. `data` points at `length` bytes representing file offsets + /// [file_offset, file_offset + length). + void retainTail(const char * data, size_t length, size_t file_offset); + size_t getFileSize() const { return file_size; } /// Average completed-read throughput (bytes/sec) since init, or 0 if not enough has been read to @@ -185,6 +193,13 @@ class Prefetcher size_t min_bytes_for_seek{}; size_t bytes_per_read_task{}; + /// Tail chunk retained by retainTail() to serve fully-contained ranges (Column/Offset Index) + /// without a second read. Written once before any prefetching, read-only afterwards. + /// [retained_tail_start, retained_tail_end) are file offsets; empty range == nothing retained. + PaddedPODArray retained_tail; + size_t retained_tail_start = 0; + size_t retained_tail_end = 0; + /// Total bytes read by completed tasks, and when reading started, for throughput estimation. std::atomic total_bytes_read{0}; std::chrono::steady_clock::time_point read_start_time{}; diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 438c4cd253a5..a0e5c7bbd20b 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -214,6 +214,13 @@ parq::FileMetaData Reader::readFileMetaData(Prefetcher & prefetcher, size_t foot if (memcmp(buf.data() + initial_read_size - 4, "PAR1", 4) != 0) throw Exception(ErrorCodes::INCORRECT_DATA, "Not a Parquet file (wrong magic bytes at the end of file)"); + /// The tail we just read also covers the Column Index / Offset Index (they sit just below the + /// FileMetaData footer). Retain it so those ranges are served from memory rather than re-read. + /// Retain the original tail bytes now, before the (rare) oversized-footer branch below mutates + /// `buf`. Especially effective together with footer_size_hint, which widens this read to span + /// the whole footer + index region of wide / many-row-group files. + prefetcher.retainTail(buf.data(), initial_read_size, file_size - initial_read_size); + int32_t metadata_size_i32 = 0; memcpy(&metadata_size_i32, buf.data() + initial_read_size - 8, 4); if (metadata_size_i32 <= 0 || size_t(metadata_size_i32) + 8 > file_size) From c581d93d5cf496fafd505fb4a5e380471e0c7e21 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 00:36:39 +0300 Subject: [PATCH 32/39] Object storage: don't shadow random-access formats with a from-start prefetch `createReadBuffer` eagerly prefetches the head of "small" objects (up to `2 * max_download_buffer_size`). That heuristic was added for many-small-files streaming formats (CSV/JSON), where reading starts at the beginning of the file, so a from-start read-ahead is exactly what will be consumed. Column-oriented random-access formats (Parquet/ORC/Arrow) instead read the `FileMetaData` footer at the *tail* first and drive their own prefetcher. For an object larger than one download buffer the from-start prefetch cannot cover the footer, so it is dropped on the first positioned read - a wasted object read (`S3GetObject`) that transfers bytes nobody consumes. Gate the eager prefetch on the format's access pattern: for random-access input formats only prefetch when the whole object fits a single download buffer (then it degenerates to one whole-file read the format serves entirely from memory, footer included); above that, leave reading to the format's tail-first prefetcher. Streaming formats keep the previous `2x` threshold, so the original small-files optimization is unchanged. `FormatFactory::checkIfFormatIsRandomAccessInput` reports whether a format is registered via `registerRandomAccessInputFormat[WithMetadata]`. The `createReadBuffer` flag defaults to false, so metadata / manifest / delete-file reads keep their current behaviour. Signed-off-by: UnamedRus --- src/Formats/FormatFactory.cpp | 8 +++++++ src/Formats/FormatFactory.h | 6 +++++ .../StorageObjectStorageSource.cpp | 24 +++++++++++++++---- src/Storages/ObjectStorage/Utils.h | 6 ++++- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index 0a978feb83ac..f6c2f7c936d4 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -1124,6 +1124,14 @@ bool FormatFactory::checkIfFormatSupportsSubsetOfColumns(const String & name, co return target.subset_of_columns_support_checker && target.subset_of_columns_support_checker(format_settings); } +bool FormatFactory::checkIfFormatIsRandomAccessInput(const String & name) const +{ + if (!exists(name)) + return false; + const auto & target = getCreators(name); + return target.random_access_input_creator || target.random_access_input_creator_with_metadata; +} + void FormatFactory::registerPrewhereSupportChecker(const String & name, PrewhereSupportChecker prewhere_support_checker) { auto & target = getOrCreateCreators(name).prewhere_support_checker; diff --git a/src/Formats/FormatFactory.h b/src/Formats/FormatFactory.h index ce7de7b8bc2b..eb8e763f5688 100644 --- a/src/Formats/FormatFactory.h +++ b/src/Formats/FormatFactory.h @@ -355,6 +355,12 @@ class FormatFactory final : private boost::noncopyable, public IHints<2> bool checkIfOutputFormatPrefersLargeBlocks(const String & name) const; bool checkIfOutputFormatIsTTYFriendly(const String & name) const; + /// True for column-oriented input formats that seek back and forth in the file (Parquet, ORC, + /// Arrow) - i.e. formats registered via registerRandomAccessInputFormat[WithMetadata]. Such + /// formats read the footer at the tail first and drive their own prefetching, so a generic + /// read-ahead from the start of the file is usually counter-productive for them. + bool checkIfFormatIsRandomAccessInput(const String & name) const; + bool checkParallelizeOutputAfterReading(const String & name, const ContextPtr & context) const; void registerAdditionalInfoForSchemaCacheGetter(const String & name, AdditionalInfoForSchemaCacheGetter additional_info_for_schema_cache_getter); diff --git a/src/Storages/ObjectStorage/StorageObjectStorageSource.cpp b/src/Storages/ObjectStorage/StorageObjectStorageSource.cpp index d004243b3131..cc98ab9600f4 100644 --- a/src/Storages/ObjectStorage/StorageObjectStorageSource.cpp +++ b/src/Storages/ObjectStorage/StorageObjectStorageSource.cpp @@ -788,7 +788,10 @@ StorageObjectStorageSource::ReaderHolder StorageObjectStorageSource::createReade { ProfileEvents::increment(ProfileEvents::ObjectStorageReadObjects); compression_method = chooseCompressionMethod(object_info->getFileName(), configuration->compression_method); - read_buf = createReadBuffer(object_info->relative_path_with_metadata, object_storage, context_, log); + const bool format_is_random_access = FormatFactory::instance().checkIfFormatIsRandomAccessInput(format_name); + read_buf = createReadBuffer( + object_info->relative_path_with_metadata, object_storage, context_, log, + /*read_settings=*/ std::nullopt, format_is_random_access); } Block initial_header = read_from_format_info.format_header; @@ -1099,7 +1102,8 @@ std::unique_ptr createReadBuffer( const ObjectStoragePtr & object_storage, const ContextPtr & context_, const LoggerPtr & log, - const std::optional & read_settings) + const std::optional & read_settings, + bool format_is_random_access) { const auto & settings = context_->getSettingsRef(); const auto & effective_read_settings = read_settings.has_value() ? read_settings.value() : context_->getReadSettings(); @@ -1166,8 +1170,20 @@ std::unique_ptr createReadBuffer( // Create a read buffer that will prefetch the first ~1 MB of the file. // When reading lots of tiny files, this prefetching almost doubles the throughput. // For bigger files, parallel reading is more useful. - const bool object_too_small = is_size_known - && object_size <= 2 * context_->getSettingsRef()[Setting::max_download_buffer_size]; + // + // The prefetch reads from the start of the file. Row/streaming formats (CSV, JSON, ...) consume + // from the start, so it is always useful for them. Column-oriented random-access formats + // (Parquet/ORC/Arrow) instead read the footer at the *tail* first and drive their own + // prefetcher: a from-start read-ahead that does not span the whole file is then dropped and + // wasted (an extra object read that transfers bytes nobody consumes). So for those formats only + // prefetch when the whole object fits a single download buffer - then it degenerates to one + // whole-file read the format serves entirely from memory (footer included). Above that, leave + // reading to the format's own tail-first prefetcher. + const size_t max_download_buffer_size = context_->getSettingsRef()[Setting::max_download_buffer_size]; + const size_t prefetch_size_limit = format_is_random_access + ? max_download_buffer_size + : 2 * max_download_buffer_size; + const bool object_too_small = is_size_known && object_size <= prefetch_size_limit; const bool use_prefetch = object_too_small && modified_read_settings.remote_fs_settings.method == RemoteFSReadMethod::threadpool && modified_read_settings.remote_fs_settings.prefetch; diff --git a/src/Storages/ObjectStorage/Utils.h b/src/Storages/ObjectStorage/Utils.h index 931ebcbed9ac..8c4c6270389c 100644 --- a/src/Storages/ObjectStorage/Utils.h +++ b/src/Storages/ObjectStorage/Utils.h @@ -34,7 +34,11 @@ std::unique_ptr createReadBuffer( const ObjectStoragePtr & object_storage, const ContextPtr & context_, const LoggerPtr & log, - const std::optional & read_settings = std::nullopt); + const std::optional & read_settings = std::nullopt, + /// Set when the consuming input format is column-oriented / random-access (Parquet/ORC/Arrow): + /// suppresses the generic from-start read-ahead unless the whole object fits one buffer, so the + /// format's own tail-first prefetcher is not shadowed by a wasted read. See createReadBuffer. + bool format_is_random_access = false); ASTs::iterator getFirstKeyValueArgument(ASTs & args); std::unordered_map parseKeyValueArguments(const ASTs & function_args, ContextPtr context); From 1e3eccfb5bafd1036e0bdc85974da205242c4a3d Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 01:49:17 +0300 Subject: [PATCH 33/39] Iceberg: seed metadata files cache on the uuid-less bootstrap read The `IcebergMetadataFilesCache` for table metadata is keyed on `(table_uuid, metadata_file_path)`, but the very first read of a table's metadata - the bootstrap in `initializePersistentTableComponents` - is issued with no `table_uuid`, because the Iceberg `table-uuid` is only learned by parsing that very file. `getMetadataJSONObject` therefore takes the uncached branch and stores nothing, so the next read (query state, now with the uuid) misses the cache and re-reads the same metadata file - two reads of one small file per cold table open. Seed the cache from the bootstrap read itself: after parsing the metadata, extract its `table-uuid` and store the already-read JSON under `getKey(table_uuid, path)`, so subsequent reads of the same file hit. No extra I/O - the buffered string is reused via `getOrSet`'s load function, which only runs when the entry is absent. This is safe and preserves the table-recreate guard: the entry is keyed by the uuid parsed from this exact file, and an immutable metadata file always yields the same `(uuid, path)`; a dropped-and-recreated table has a different `table-uuid` and thus a different key. Only the top-level table metadata carries `table-uuid`; other JSON is left untouched. Signed-off-by: UnamedRus --- .../ObjectStorage/DataLakes/Iceberg/Utils.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/Utils.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/Utils.cpp index 3ac3042dc50c..6c0abbe885e9 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/Utils.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/Utils.cpp @@ -502,7 +502,24 @@ Poco::JSON::Object::Ptr getMetadataJSONObject( Poco::JSON::Parser parser; /// For some reason base/base/JSON.h can not parse this json file Poco::Dynamic::Var json = parser.parse(metadata_json_str); - return json.extract(); + auto metadata_object = json.extract(); + + /// The very first read of a table's metadata (bootstrap, in initializePersistentTableComponents) + /// is issued with no table_uuid - the uuid is only learned by parsing this very file - so it + /// takes the uncached branch above and stores nothing. Every later read keys the cache on + /// (table_uuid, path) and would therefore miss and re-read the same file. Seed the cache here, + /// using the uuid we just parsed, so those later reads hit. Only the top-level table metadata + /// carries `table-uuid`; other JSON (e.g. none) is skipped. Immutable per (uuid, path), so this + /// is safe and preserves the table-recreate guard (a recreated table has a different uuid). + if (metadata_cache && !table_uuid.has_value() && metadata_object->has(f_table_uuid)) + { + auto parsed_uuid = normalizeUuid(metadata_object->getValue(f_table_uuid)); + metadata_cache->getOrSetTableMetadata( + IcebergMetadataFilesCache::getKey(parsed_uuid, metadata_file_path), + [&metadata_json_str]() { return metadata_json_str; }); + } + + return metadata_object; } /// Returns type and required From e65e1a3b66c5d3ccfb6d07d02169c6b2d1e547f8 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 01:49:17 +0300 Subject: [PATCH 34/39] Iceberg: use the catalog-provided table-uuid for the first metadata read A data-lake catalog (Iceberg REST, Glue, ...) already parses the Iceberg `table-uuid` out of its `LoadTable` response (`RestCatalog::setTableUUID`), but it was only used to build the ClickHouse `StorageID` - it never reached the Iceberg metadata layer. So the bootstrap metadata read still ran with no uuid and could not key the metadata files cache, forcing a redundant re-read of the same `metadata.json` on the following query-state read. Forward the catalog's `table-uuid` as the `iceberg_metadata_table_uuid` storage setting (`DatabaseDataLake`), and have `initializePersistentTableComponents` read it and pass it to `getLatestOrExplicitMetadataFileAndVersion` and `getMetadataJSONObject` instead of `std::nullopt`. The bootstrap read then keys the cache on `(table_uuid, path)` on the first read, and the later read hits. Resolution is unchanged: the catalog also sets `iceberg_metadata_file_path`, so `getLatestOrExplicitMetadataFileAndVersion` still takes the explicit-path branch (the uuid argument is inert there). For access paths with no catalog (bare-path `iceberg()` table function, Hadoop / version-hint tables) the setting stays unset and the uuid is still learned from the file - handled by the cache seeding in `getMetadataJSONObject`. Signed-off-by: UnamedRus --- src/Databases/DataLake/DatabaseDataLake.cpp | 7 +++++++ .../DataLakes/Iceberg/IcebergMetadata.cpp | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Databases/DataLake/DatabaseDataLake.cpp b/src/Databases/DataLake/DatabaseDataLake.cpp index a2e737182669..488fcba7eed7 100644 --- a/src/Databases/DataLake/DatabaseDataLake.cpp +++ b/src/Databases/DataLake/DatabaseDataLake.cpp @@ -105,6 +105,7 @@ namespace Setting namespace DataLakeStorageSetting { extern const DataLakeStorageSettingsString iceberg_metadata_file_path; + extern const DataLakeStorageSettingsString iceberg_metadata_table_uuid; extern const DataLakeStorageSettingsBool iceberg_use_version_hint; } @@ -637,6 +638,12 @@ StoragePtr DatabaseDataLake::tryGetTableImpl(const String & name, ContextPtr con (*storage_settings)[DB::DataLakeStorageSetting::iceberg_metadata_file_path] = metadata_location; } + /// Forward the Iceberg table-uuid the catalog already parsed from its LoadTable response (see + /// RestCatalog::setTableUUID). It lets the Iceberg metadata layer key its files cache on the + /// first (bootstrap) metadata read, avoiding a redundant re-read of the same metadata file. + if (auto catalog_table_uuid = table_metadata.getTableUUID(); catalog_table_uuid.has_value()) + (*storage_settings)[DB::DataLakeStorageSetting::iceberg_metadata_table_uuid] = *catalog_table_uuid; + const auto configuration = getConfiguration(storage_type, storage_settings); /// HACK: Hacky-hack to enable lazy load diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp index a7bd62ccd110..293328cea8d7 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp @@ -169,11 +169,24 @@ Iceberg::PersistentTableComponents IcebergMetadata::initializePersistentTableCom ContextPtr context_, LoggerPtr log) { + /// A catalog (e.g. Iceberg REST) already knows the table's Iceberg table-uuid from its LoadTable + /// response and forwards it as the iceberg_metadata_table_uuid storage setting (see + /// DatabaseDataLake / RestCatalog::setTableUUID). When present, use it to key the metadata cache + /// on this very first read, so the bootstrap read stores the metadata under (table_uuid, path) + /// and the later per-query read hits instead of re-reading the same metadata file. When absent + /// (bare-path table function, or Hadoop / version-hint tables with no catalog) the uuid can only + /// come from the file itself, so this stays nullopt and getMetadataJSONObject seeds the cache + /// after parsing. + const auto & data_lake_settings = configuration->getDataLakeSettings(); + std::optional known_table_uuid; + if (data_lake_settings[DataLakeStorageSetting::iceberg_metadata_table_uuid].changed) + known_table_uuid = normalizeUuid(data_lake_settings[DataLakeStorageSetting::iceberg_metadata_table_uuid].value); + const auto [metadata_version, metadata_file_path, compression_method] - = getLatestOrExplicitMetadataFileAndVersion(object_storage, configuration->getPathForRead().path, configuration->getDataLakeSettings(), cache_ptr, context_, log.get(), std::nullopt, CompressionMethod::None, true); + = getLatestOrExplicitMetadataFileAndVersion(object_storage, configuration->getPathForRead().path, data_lake_settings, cache_ptr, context_, log.get(), known_table_uuid, CompressionMethod::None, true); LOG_DEBUG(log, "Latest metadata file path is {}, version {}", metadata_file_path, metadata_version); auto metadata_object - = getMetadataJSONObject(metadata_file_path, object_storage, cache_ptr, context_, log, compression_method, std::nullopt); + = getMetadataJSONObject(metadata_file_path, object_storage, cache_ptr, context_, log, compression_method, known_table_uuid); Int32 format_version = metadata_object->getValue(f_format_version); String table_location = metadata_object->getValue(f_location); std::optional table_uuid = std::nullopt; From e1b807a9bb022db747da1b9905851e7d0be4ff89 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 03:24:36 +0300 Subject: [PATCH 35/39] Object storage: identity cache + GetObjectAttributes to avoid per-file HEAD Opening an object went through a HEAD (getObjectMetadata) on every read to learn its size and ETag, and the result was not cached process-wide, so a scan re-HEADed every candidate file on every query (traced: ~1 S3 HeadObject per candidate file, e.g. ~1900 HEADs to read ~550 files). The HEAD is also mandatory-before-cache-lookup, because the filesystem / page / parquet-metadata caches key on (path, etag) and the etag comes from the HEAD. Introduce a process-wide identity cache and a single-request metadata fetch: * `S3::getObjectIdentity` issues one `GetObjectAttributes` request (size + ETag + multipart part sizes) and falls back to a plain HEAD if the API is unsupported, denied, or errors - safe against S3-compatible stores. The ETag is quote-normalized so the GetObjectAttributes and HEAD paths yield the same identity (they are used as cache keys). Adds the `GetObjectAttributes` wrapper to `S3::Client` (mirrors `GetObjectTagging`) and the request alias. * `ObjectStorageIdentityCache` (experimental singleton) maps path -> {etag, size, part_offsets}. `S3ObjectStorage::getObjectMetadata` consults it on the identity path (no tags requested): a hit skips the request entirely; a miss fetches via `getObjectIdentity` and populates it. The tags path and the credentials-refresh retry are unchanged. * `ObjectMetadata` gains `part_offsets` (cumulative multipart part offsets), derived from the per-part sizes; consumers use it to align reads. * `IcebergDataObjectInfo` no longer pre-populates object metadata from the manifest, so Iceberg data files also go through this single identity path and get the real ETag and multipart layout instead of a synthesized identity. New ProfileEvents: `S3GetObjectAttributes`, `ObjectStorageIdentityCacheHits`, `ObjectStorageIdentityCacheMisses`. Note: the identity path returns no user-metadata attributes (GetObjectAttributes does not provide them); callers that need them still use the tags path. The cache is an experimental singleton (not Context-managed / not SYSTEM DROP-able) and should be promoted before productionization. Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 5 ++ .../ObjectStorages/IObjectStorage.h | 4 ++ .../ObjectStorageIdentityCache.cpp | 47 +++++++++++++ .../ObjectStorageIdentityCache.h | 51 ++++++++++++++ .../ObjectStorages/S3/S3ObjectStorage.cpp | 51 +++++++++++++- src/IO/S3/Client.cpp | 6 ++ src/IO/S3/Client.h | 2 + src/IO/S3/Requests.h | 2 + src/IO/S3/getObjectInfo.cpp | 66 +++++++++++++++++++ src/IO/S3/getObjectInfo.h | 16 +++++ .../Iceberg/IcebergDataObjectInfo.cpp | 6 ++ 11 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.cpp create mode 100644 src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.h diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 36fab9cf5469..2c4c7f7fb467 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -754,6 +754,9 @@ The server successfully detected this situation and will download merged part fr M(S3CopyObject, "Number of S3 API CopyObject calls.", ValueType::Number) \ M(S3ListObjects, "Number of S3 API ListObjects calls.", ValueType::Number) \ M(S3HeadObject, "Number of S3 API HeadObject calls.", ValueType::Number) \ + M(S3GetObjectAttributes, "Number of S3 API GetObjectAttributes calls.", ValueType::Number) \ + M(ObjectStorageIdentityCacheHits, "Number of object-storage identity (size/etag/part-offsets) cache hits, avoiding a HEAD/GetObjectAttributes.", ValueType::Number) \ + M(ObjectStorageIdentityCacheMisses, "Number of object-storage identity cache misses that triggered a metadata request.", ValueType::Number) \ M(S3GetObjectTagging, "Number of S3 API GetObjectTagging calls.", ValueType::Number) \ M(S3CreateMultipartUpload, "Number of S3 API CreateMultipartUpload calls.", ValueType::Number) \ M(S3UploadPartCopy, "Number of S3 API UploadPartCopy calls.", ValueType::Number) \ @@ -767,6 +770,7 @@ The server successfully detected this situation and will download merged part fr M(DiskS3CopyObject, "Number of DiskS3 API CopyObject calls.", ValueType::Number) \ M(DiskS3ListObjects, "Number of DiskS3 API ListObjects calls.", ValueType::Number) \ M(DiskS3HeadObject, "Number of DiskS3 API HeadObject calls.", ValueType::Number) \ + M(DiskS3GetObjectAttributes, "Number of DiskS3 API GetObjectAttributes calls.", ValueType::Number) \ M(DiskS3GetObjectTagging, "Number of DiskS3 API GetObjectTagging calls.", ValueType::Number) \ M(DiskS3CreateMultipartUpload, "Number of DiskS3 API CreateMultipartUpload calls.", ValueType::Number) \ M(DiskS3UploadPartCopy, "Number of DiskS3 API UploadPartCopy calls.", ValueType::Number) \ @@ -1450,6 +1454,7 @@ The server successfully detected this situation and will download merged part fr M(ParquetPrefetcherReadSeekAndRead, "The total number of reads with ReadMode::SeekAndRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadEntireFile, "The total number of read with ReadMode::EntireFileIsInMemory by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherServedFromRetainedTail, "The number of ranges (e.g. Column/Offset Index) served from the retained footer tail by DB::Parquet::Prefetcher without issuing a read", ValueType::Number) \ + M(ParquetPrefetcherPartAlignedTasks, "The number of read tasks whose coalescing was constrained to a single S3 multipart-upload part boundary by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetRowsFilterExpression, "The total number of rows that were passed through filter", ValueType::Number) \ M(ParquetColumnsFilterExpression, "The total number of columns that were passed through filter", ValueType::Number) \ M(FilterTransformPassedRows, "Number of rows that passed the filter in the query", ValueType::Number) \ diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h index 7f6395152ff0..8884f4d15b27 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h @@ -110,6 +110,10 @@ struct ObjectMetadata std::string etag; ObjectAttributes tags; ObjectAttributes attributes; + /// Cumulative start offsets of the object's multipart-upload parts (part i covers + /// [part_offsets[i], part_offsets[i+1])). Populated best-effort via GetObjectAttributes; empty + /// when unknown or single-PUT. Used to align reads to part boundaries. + std::vector part_offsets; }; struct DataLakeObjectMetadata; diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.cpp new file mode 100644 index 000000000000..70d2fea899db --- /dev/null +++ b/src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.cpp @@ -0,0 +1,47 @@ +#include + +#include + +namespace ProfileEvents +{ + extern const Event ObjectStorageIdentityCacheHits; + extern const Event ObjectStorageIdentityCacheMisses; +} + +namespace DB +{ + +ObjectStorageIdentityCache & ObjectStorageIdentityCache::instance() +{ + static ObjectStorageIdentityCache cache; + return cache; +} + +std::optional ObjectStorageIdentityCache::tryGet(const String & key) const +{ + std::lock_guard lock(mutex); + auto it = map.find(key); + if (it == map.end()) + { + ProfileEvents::increment(ProfileEvents::ObjectStorageIdentityCacheMisses); + return std::nullopt; + } + ProfileEvents::increment(ProfileEvents::ObjectStorageIdentityCacheHits); + return it->second; +} + +void ObjectStorageIdentityCache::set(const String & key, ObjectStorageIdentity identity) +{ + std::lock_guard lock(mutex); + if (map.size() >= max_entries) + map.clear(); + map[key] = std::move(identity); +} + +void ObjectStorageIdentityCache::clear() +{ + std::lock_guard lock(mutex); + map.clear(); +} + +} diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.h b/src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.h new file mode 100644 index 000000000000..60ca6cb49293 --- /dev/null +++ b/src/Disks/DiskObjectStorage/ObjectStorages/ObjectStorageIdentityCache.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace DB +{ + +/// EXPERIMENTAL, process-wide cache of object-storage "identity": the bits needed to open an object +/// without a fresh HEAD - its size, ETag, and (if multipart-uploaded) the byte offsets of its parts. +/// +/// It exists to break the ordering that forces a HEAD before every open: object caches key on +/// (path, etag), so the etag must be known up front. Here we key by path and store the etag in the +/// value, learned once (via GetObjectAttributes / a GET response) and reused. Multipart part offsets +/// ride along so reads can be aligned to part boundaries without re-probing. +/// +/// This is a deliberately minimal singleton (plain mutex + map with a coarse size cap), meant for +/// measuring the win on this research branch. Productionizing should promote it to a Context-managed +/// CacheBase with proper eviction, server settings, and SYSTEM DROP support. +struct ObjectStorageIdentity +{ + String etag; + UInt64 size = 0; + bool is_size_known = true; + /// Cumulative start offset of each multipart part (part i covers [part_offsets[i], part_offsets[i+1])). + /// Empty when unknown or the object is a single PUT. + std::vector part_offsets; +}; + +class ObjectStorageIdentityCache +{ +public: + static ObjectStorageIdentityCache & instance(); + + std::optional tryGet(const String & key) const; + void set(const String & key, ObjectStorageIdentity identity); + void clear(); + +private: + /// Coarse cap: on overflow the whole map is cleared (experimental; a real cache would use LRU). + static constexpr size_t max_entries = 1'000'000; + + mutable std::mutex mutex; + std::unordered_map map; +}; + +} diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 34eb1eaebb9d..3862c9ed4d56 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -535,11 +536,40 @@ std::optional S3ObjectStorage::tryGetObjectMetadata(const std::s ObjectMetadata S3ObjectStorage::getObjectMetadata(const std::string & path, bool with_tags) const { + /// When the caller only needs identity (size / etag / part offsets, not tags or user metadata) we + /// take a fast path: consult the process-wide identity cache to skip the metadata request entirely + /// on repeat opens, and on a miss fetch via GetObjectAttributes (one request for size + etag + + /// multipart part sizes, with a HEAD fallback) instead of a plain HEAD. This is what removes the + /// per-file HEAD on lake scans. The tags path is unchanged. See ObjectStorageIdentityCache. + const bool identity_only = !with_tags; + const String identity_key = uri.bucket + "/" + path; + + if (identity_only) + { + if (auto cached = ObjectStorageIdentityCache::instance().tryGet(identity_key)) + { + ObjectMetadata result; + result.size_bytes = cached->size; + result.is_size_known = cached->is_size_known; + result.etag = cached->etag; + result.part_offsets = cached->part_offsets; + return result; + } + } + auto settings_ptr = s3_settings.get(); + + auto fetch = [&](const S3::Client & c) -> S3::ObjectInfo + { + if (identity_only) + return S3::getObjectIdentity(c, uri.bucket, path, /*version_id=*/ {}); + return S3::getObjectInfo(c, uri.bucket, path, /*version_id=*/ {}, /*with_metadata=*/ true, /*with_tags=*/ with_tags); + }; + S3::ObjectInfo object_info; try { - object_info = S3::getObjectInfo(*client.get(), uri.bucket, path, /*version_id=*/ {}, /*with_metadata=*/ true, /*with_tags=*/ with_tags); + object_info = fetch(*client.get()); } catch (DB::Exception & e) { @@ -550,7 +580,7 @@ ObjectMetadata S3ObjectStorage::getObjectMetadata(const std::string & path, bool if (new_client) { client.set(std::move(new_client)); - object_info = S3::getObjectInfo(*client.get(), uri.bucket, path, /*version_id=*/ {}, /*with_metadata=*/ true, /*with_tags=*/ with_tags); + object_info = fetch(*client.get()); updated = true; } } @@ -569,6 +599,23 @@ ObjectMetadata S3ObjectStorage::getObjectMetadata(const std::string & path, bool result.tags = std::move(object_info.tags); result.attributes = object_info.metadata; + /// GetObjectAttributes returns per-part sizes; convert to cumulative start offsets. + if (!object_info.part_sizes.empty()) + { + result.part_offsets.reserve(object_info.part_sizes.size()); + uint64_t offset = 0; + for (size_t part_size : object_info.part_sizes) + { + result.part_offsets.push_back(offset); + offset += part_size; + } + } + + if (identity_only) + ObjectStorageIdentityCache::instance().set( + identity_key, + ObjectStorageIdentity{result.etag, result.size_bytes, result.is_size_known, result.part_offsets}); + return result; } diff --git a/src/IO/S3/Client.cpp b/src/IO/S3/Client.cpp index af6f0a2e6894..1b4ba03a2945 100644 --- a/src/IO/S3/Client.cpp +++ b/src/IO/S3/Client.cpp @@ -506,6 +506,12 @@ Model::GetObjectTaggingOutcome Client::GetObjectTagging(GetObjectTaggingRequest doRequest(request, [this](const Model::GetObjectTaggingRequest & req) { return GetObjectTagging(req); })); } +Model::GetObjectAttributesOutcome Client::GetObjectAttributes(GetObjectAttributesRequest & request) const +{ + return processRequestResult( + doRequest(request, [this](const Model::GetObjectAttributesRequest & req) { return GetObjectAttributes(req); })); +} + Model::ListObjectsV2Outcome Client::ListObjectsV2(ListObjectsV2Request & request) const { return doRequestWithRetryNetworkErrors( diff --git a/src/IO/S3/Client.h b/src/IO/S3/Client.h index ad4d685d88a2..363c169d81e2 100644 --- a/src/IO/S3/Client.h +++ b/src/IO/S3/Client.h @@ -211,6 +211,7 @@ class Client : private Aws::S3::S3Client Model::ListObjectsV2Outcome ListObjectsV2(ListObjectsV2Request & request) const; Model::ListObjectsOutcome ListObjects(ListObjectsRequest & request) const; Model::GetObjectOutcome GetObject(GetObjectRequest & request) const; + Model::GetObjectAttributesOutcome GetObjectAttributes(GetObjectAttributesRequest & request) const; Model::AbortMultipartUploadOutcome AbortMultipartUpload(AbortMultipartUploadRequest & request) const; Model::CreateMultipartUploadOutcome CreateMultipartUpload(CreateMultipartUploadRequest & request) const; @@ -276,6 +277,7 @@ class Client : private Aws::S3::S3Client using Aws::S3::S3Client::ListObjectsV2; using Aws::S3::S3Client::ListObjects; using Aws::S3::S3Client::GetObject; + using Aws::S3::S3Client::GetObjectAttributes; using Aws::S3::S3Client::AbortMultipartUpload; using Aws::S3::S3Client::CreateMultipartUpload; diff --git a/src/IO/S3/Requests.h b/src/IO/S3/Requests.h index aa21602674f0..1db171fa7a3e 100644 --- a/src/IO/S3/Requests.h +++ b/src/IO/S3/Requests.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -157,6 +158,7 @@ class HeadObjectRequest: public ExtendedRequest using ListObjectsV2Request = ExtendedRequest; using ListObjectsRequest = ExtendedRequest; using GetObjectRequest = ExtendedRequest; +using GetObjectAttributesRequest = ExtendedRequest; using GetObjectTaggingRequest = ExtendedRequest; class UploadPartRequest : public ExtendedRequest diff --git a/src/IO/S3/getObjectInfo.cpp b/src/IO/S3/getObjectInfo.cpp index 1cdcd9f94e37..74f1517cc440 100644 --- a/src/IO/S3/getObjectInfo.cpp +++ b/src/IO/S3/getObjectInfo.cpp @@ -4,12 +4,16 @@ #if USE_AWS_S3 +#include + namespace ProfileEvents { extern const Event S3GetObjectTagging; extern const Event S3HeadObject; + extern const Event S3GetObjectAttributes; extern const Event DiskS3GetObjectTagging; extern const Event DiskS3HeadObject; + extern const Event DiskS3GetObjectAttributes; } @@ -38,6 +42,39 @@ namespace return client.HeadObject(req); } + Aws::S3::Model::GetObjectAttributesOutcome getObjectAttributes( + const S3::Client & client, + const String & bucket, + const String & key, + const String & version_id) + { + ProfileEvents::increment(ProfileEvents::S3GetObjectAttributes); + if (client.isClientForDisk()) + ProfileEvents::increment(ProfileEvents::DiskS3GetObjectAttributes); + + S3::GetObjectAttributesRequest req; + req.SetBucket(bucket); + req.SetKey(key); + if (!version_id.empty()) + req.SetVersionId(version_id); + req.SetObjectAttributes({ + Aws::S3::Model::ObjectAttributes::ETag, + Aws::S3::Model::ObjectAttributes::ObjectSize, + Aws::S3::Model::ObjectAttributes::ObjectParts}); + + return client.GetObjectAttributes(req); + } + + /// GetObjectAttributes returns the ETag without surrounding quotes, whereas HeadObject returns it + /// quoted. Normalize (strip quotes) so identities are consistent regardless of which path produced + /// them - important because they are used as cache keys. + String stripQuotes(String s) + { + if (s.size() >= 2 && s.front() == '"' && s.back() == '"') + s = s.substr(1, s.size() - 2); + return s; + } + Aws::S3::Model::GetObjectTaggingOutcome getObjectTagging( const S3::Client & client, const String & bucket, @@ -180,6 +217,35 @@ ObjectInfo getObjectInfo( getAuthenticationErrorHint(error.GetErrorType())); } +ObjectInfo getObjectIdentity( + const S3::Client & client, + const String & bucket, + const String & key, + const String & version_id) +{ + { + Expect404ResponseScope scope; // a not-found here just falls through to HEAD below + auto outcome = getObjectAttributes(client, bucket, key, version_id); + if (outcome.IsSuccess()) + { + const auto & result = outcome.GetResult(); + ObjectInfo object_info; + object_info.size = static_cast(result.GetObjectSize()); + object_info.is_size_known = true; + object_info.etag = stripQuotes(result.GetETag()); + for (const auto & part : result.GetObjectParts().GetParts()) + object_info.part_sizes.push_back(static_cast(part.GetSize())); + return object_info; + } + /// GetObjectAttributes unsupported (many S3-compatible stores), denied (distinct IAM action), + /// or otherwise failed - fall back to a plain HEAD. Best-effort: we lose part_sizes only. + } + + ObjectInfo object_info = getObjectInfo(client, bucket, key, version_id, /*with_metadata=*/ false, /*with_tags=*/ false); + object_info.etag = stripQuotes(object_info.etag); + return object_info; +} + size_t getObjectSize( const S3::Client & client, const String & bucket, diff --git a/src/IO/S3/getObjectInfo.h b/src/IO/S3/getObjectInfo.h index 314d81c6daa5..0c5455910d76 100644 --- a/src/IO/S3/getObjectInfo.h +++ b/src/IO/S3/getObjectInfo.h @@ -3,6 +3,7 @@ #include "config.h" #if USE_AWS_S3 +#include #include #include #include @@ -19,6 +20,10 @@ struct ObjectInfo String etag; ObjectAttributes tags; // Set only if getObjectInfo() is called with `with_tags = true` ObjectAttributes metadata = {}; /// Set only if getObjectInfo() is called with `with_metadata = true`. + /// Sizes of the object's multipart-upload parts (offsets = prefix sums). Set only by + /// getObjectIdentity() when the object was multipart-uploaded and GetObjectAttributes succeeded; + /// empty for single-PUT objects or when the call fell back to HEAD. + std::vector part_sizes; }; /// Ignore if object does not exist @@ -38,6 +43,17 @@ ObjectInfo getObjectInfo( bool with_metadata = false, bool with_tags = false); +/// Fetches size + etag (+ multipart part sizes) in a single GetObjectAttributes request instead of a +/// HEAD, so a caller that also wants the multipart layout gets it without a second round-trip. Falls +/// back to getObjectInfo() (HEAD) if GetObjectAttributes is unsupported, denied, or otherwise errors, +/// so it is safe against S3-compatible stores that lack the API. `part_sizes` is populated only on +/// the GetObjectAttributes path for multipart objects; it is empty on the fallback path. +ObjectInfo getObjectIdentity( + const S3::Client & client, + const String & bucket, + const String & key, + const String & version_id = {}); + ObjectAttributes getObjectTags( const S3::Client & client, const String & bucket, diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp index 6598ad91b234..fd887a1e2d17 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDataObjectInfo.cpp @@ -83,6 +83,12 @@ IcebergDataObjectInfo::IcebergDataObjectInfo( data_manifest_file_entry_->parsed_entry->record_count, data_manifest_file_entry_->parsed_entry->file_size_in_bytes} { + /// Note: object identity (size / etag / multipart part offsets) is intentionally NOT pre-populated + /// from the manifest here. It is fetched once via GetObjectAttributes and cached by the + /// object-storage identity cache (see S3ObjectStorage::getObjectMetadata / ObjectStorageIdentityCache), + /// so all object reads - Iceberg data files included - go through one path that yields the real + /// ETag (needed by the filesystem / page / parquet-metadata caches) and the multipart layout + /// (needed for read alignment). Pre-populating metadata here would skip that path and lose both. #if USE_PARQUET /// Precompute a footer-size hint from the manifest stats so the parquet reader can fetch the /// FileMetaData in a single tail read (see Parquet::estimateParquetFooterSize). Parquet only; From 8afd7bdca735125bc3b1710ba03a170721bc634a Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 03:24:36 +0300 Subject: [PATCH 36/39] Parquet v3: align coalesced reads to S3 multipart part boundaries Experimental, off by default. When the object's multipart-upload layout is known (from GetObjectAttributes, via the object-storage identity cache), constrain the Prefetcher's range coalescing to a single part window so one read never straddles two parts - an AWS byte-range best practice for multipart-uploaded objects. * `ReadOptions.multipart_part_offsets` carries the part boundaries; populated in `ParquetV3BlockInputFormat` from `ObjectMetadata.part_offsets`, gated by the new setting so it can be A/B-compared. * `Prefetcher` keeps the offsets and, in `pickRangesAndCreateTaskIfNotExists`, stops extending a task past the boundaries of the part containing the initial range. This only constrains coalescing; a single requested range larger than a part is left as is (would require splitting). * New setting `input_format_parquet_align_reads_to_multipart_boundaries` (default false) and ProfileEvent `ParquetPrefetcherPartAlignedTasks` (counts tasks whose coalescing was cut at a part boundary) for measurement. Signed-off-by: UnamedRus --- src/Core/FormatFactorySettings.h | 3 ++ src/Core/SettingsChangesHistory.cpp | 1 + src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 1 + .../Formats/Impl/Parquet/Prefetcher.cpp | 34 +++++++++++++++++++ .../Formats/Impl/Parquet/Prefetcher.h | 4 +++ .../Formats/Impl/Parquet/ReadCommon.h | 8 +++++ .../Impl/ParquetV3BlockInputFormat.cpp | 10 ++++++ 8 files changed, 62 insertions(+) diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 052e8dbadde1..4a3c4cc388b5 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -215,6 +215,9 @@ Load the Parquet Column Index for read columns that have no predicate of their o )", 0) \ DECLARE(Bool, input_format_parquet_fill_constant_pages, false, R"( Experimental. When a Parquet column is single-valued over some data pages but not the whole row subgroup, fill those pages' rows from the per-page Column Index statistics instead of reading and decoding them (mixed-topology subgroups). Extends the constant-column optimization below the subgroup granularity. Requires `input_format_parquet_use_constant_column_optimization`; disabled by default. +)", 0) \ + DECLARE(Bool, input_format_parquet_align_reads_to_multipart_boundaries, false, R"( +Experimental. Align coalesced Parquet read requests to the boundaries of the object's S3 multipart-upload parts, so a single read never straddles two parts (an AWS best practice). Requires the per-file multipart layout, learned via GetObjectAttributes and cached (see the object-storage identity cache); has no effect for single-part objects or stores that don't expose part info. Disabled by default. )", 0) \ DECLARE(Double, input_format_parquet_prefetch_bandwidth_hide_seconds, 0, R"( Read back-pressure for the Parquet v3 reader. When greater than zero, stop prefetching more compressed data pages ahead of decoding once the in-flight compressed bytes exceed this many seconds' worth of the measured read throughput (i.e. once the storage link is kept busy). Prevents buffering compressed data far beyond what bandwidth can consume. 0 disables the back-pressure (compressed prefetch is then bounded only by its memory budget). diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 6fe9aa834bc2..b6d3b4919107 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -45,6 +45,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_prefetch_bandwidth_hide_seconds", 0, 0, "New setting: read back-pressure for the Parquet v3 reader; stop prefetching compressed data pages once in-flight compressed bytes exceed this many seconds of measured read throughput. 0 (the default and the pre-existing behavior) disables the back-pressure."}, {"input_format_parquet_use_column_index_for_constant_columns", false, false, "New setting: load the Parquet Column Index for read columns without a predicate so the constant-column optimization can skip data pages that are single-valued over a row subgroup. Disabled by default (costs a small extra read)."}, {"input_format_parquet_fill_constant_pages", false, false, "New experimental setting: fill single-valued Parquet data pages from per-page Column Index statistics instead of decoding them, even when only part of a row subgroup is constant. Disabled by default."}, + {"input_format_parquet_align_reads_to_multipart_boundaries", false, false, "New experimental setting: align coalesced Parquet read requests to S3 multipart-upload part boundaries so a single read never straddles two parts. Disabled by default."}, {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, {"output_format_image_width", 1024, 1024, "New setting controlling the width of the output image for image output formats such as PNG."}, diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index f6c2f7c936d4..b982d53628dc 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -222,6 +222,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.use_constant_column_optimization = settings[Setting::input_format_parquet_use_constant_column_optimization]; format_settings.parquet.use_column_index_for_constant_columns = settings[Setting::input_format_parquet_use_column_index_for_constant_columns]; format_settings.parquet.fill_constant_pages = settings[Setting::input_format_parquet_fill_constant_pages]; + format_settings.parquet.align_reads_to_multipart_boundaries = settings[Setting::input_format_parquet_align_reads_to_multipart_boundaries]; format_settings.parquet.prefetch_bandwidth_hide_seconds = settings[Setting::input_format_parquet_prefetch_bandwidth_hide_seconds]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index d1fd824a3de9..0878c942fc4f 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -352,6 +352,7 @@ struct FormatSettings bool use_constant_column_optimization = true; bool use_column_index_for_constant_columns = false; bool fill_constant_pages = false; + bool align_reads_to_multipart_boundaries = false; double prefetch_bandwidth_hide_seconds = 0; bool enable_json_parsing = true; diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index 02d68f553ea5..4a1837f74b8a 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -9,6 +9,8 @@ #include #include +#include +#include namespace DB::ErrorCodes { @@ -23,6 +25,7 @@ namespace ProfileEvents extern const Event ParquetPrefetcherReadSeekAndRead; extern const Event ParquetPrefetcherReadEntireFile; extern const Event ParquetPrefetcherServedFromRetainedTail; + extern const Event ParquetPrefetcherPartAlignedTasks; } namespace DB::Parquet @@ -32,6 +35,7 @@ void Prefetcher::init(ReadBuffer * reader_, const ReadOptions & options, FormatP { min_bytes_for_seek = options.min_bytes_for_seek; bytes_per_read_task = options.bytes_per_read_task; + multipart_part_offsets = options.multipart_part_offsets; parser_shared_resources = parser_shared_resources_; determineReadModeAndFileSize(reader_, options); range_sets.resize(1); @@ -363,6 +367,21 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, size_t end_idx = range_idx + 1; size_t total_length_of_covered_ranges = end_offset - start_offset; + /// EXPERIMENTAL part-boundary alignment: keep this coalesced task within a single S3 multipart + /// part, so one read never straddles a part boundary (an AWS best practice). Constrain coalescing + /// to [part_lo, part_hi) - the part containing the initial requested range's start. This only + /// limits *coalescing* across a boundary; a single requested range larger than a part is left as + /// is (would require splitRange). Empty offsets -> no constraint. + size_t part_lo = 0; + size_t part_hi = std::numeric_limits::max(); + if (!multipart_part_offsets.empty()) + { + auto it = std::upper_bound(multipart_part_offsets.begin(), multipart_part_offsets.end(), start_offset); + part_hi = (it == multipart_part_offsets.end()) ? file_size : *it; + part_lo = (it == multipart_part_offsets.begin()) ? 0 : *std::prev(it); + } + bool part_boundary_constrained = false; + /// Go left. size_t initial_offset = start_offset; for (size_t idx = range_idx; idx > 0; --idx) @@ -373,6 +392,12 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, !r.request->allow_incidental_read.load(std::memory_order_relaxed)) // range wants to be coalesced break; + if (r.start < part_lo) // would cross below this part's boundary + { + part_boundary_constrained = true; + break; + } + const auto s = r.request->state.load(std::memory_order_relaxed); if (s == RequestState::State::HasRange) { @@ -408,6 +433,12 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, !r.request->allow_incidental_read.load(std::memory_order_relaxed)) break; + if (r.end > part_hi) // would cross above this part's boundary + { + part_boundary_constrained = true; + break; + } + const auto s = r.request->state.load(std::memory_order_relaxed); if (s == RequestState::State::HasRange) { @@ -424,6 +455,9 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, } } + if (part_boundary_constrained) + ProfileEvents::increment(ProfileEvents::ParquetPrefetcherPartAlignedTasks); + /// Create task. Task & task = tasks.emplace_back(); task.offset = start_offset; diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index d9f9813ad324..f33878f258b4 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -193,6 +193,10 @@ class Prefetcher size_t min_bytes_for_seek{}; size_t bytes_per_read_task{}; + /// Cumulative start offsets of the object's S3 multipart-upload parts, used to keep coalesced + /// read tasks within a single part. Empty = no alignment. Set once in init(), read-only after. + std::vector multipart_part_offsets; + /// Tail chunk retained by retainTail() to serve fully-contained ranges (Column/Offset Index) /// without a second read. Written once before any prefetching, read-only afterwards. /// [retained_tail_start, retained_tail_end) are file offsets; empty range == nothing retained. diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.h b/src/Processors/Formats/Impl/Parquet/ReadCommon.h index 9c39e474c47e..b55954f0ee5d 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.h +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.h @@ -4,6 +4,7 @@ #include #include +#include namespace DB { @@ -47,6 +48,13 @@ struct ReadOptions /// correctness: an undershoot falls back to a second read, an overshoot reads a slightly larger /// (already-clamped) tail. size_t footer_metadata_size_hint = 0; + + /// Cumulative start offsets of the object's S3 multipart-upload parts (part i covers + /// [multipart_part_offsets[i], multipart_part_offsets[i+1])). Learned from GetObjectAttributes + /// (see ObjectStorageIdentityCache) and used to align coalesced read tasks to part boundaries so + /// a single read never straddles two parts. Empty = unknown / single-part -> no alignment. + /// EXPERIMENTAL: used to measure the effect of part-boundary-aligned reads. + std::vector multipart_part_offsets; }; /// Estimate the serialized size of a parquet FileMetaData footer, to size the initial tail read. diff --git a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp index 81355e1e41d3..df20d60cd7a7 100644 --- a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp +++ b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp @@ -64,6 +64,16 @@ ParquetV3BlockInputFormat::ParquetV3BlockInputFormat( if (object_with_metadata && object_with_metadata->footer_size_hint) read_options.footer_metadata_size_hint = *object_with_metadata->footer_size_hint; + /// Multipart part boundaries (from GetObjectAttributes via the identity cache), used to align + /// coalesced reads to part boundaries. EXPERIMENTAL: for measuring part-aligned reads. Gated by + /// input_format_parquet_align_reads_to_multipart_boundaries so it can be A/B-compared; when off, + /// the offsets stay empty and the Prefetcher coalesces as usual. + if (read_options.format.parquet.align_reads_to_multipart_boundaries + && object_with_metadata && object_with_metadata->metadata && !object_with_metadata->metadata->part_offsets.empty()) + read_options.multipart_part_offsets.assign( + object_with_metadata->metadata->part_offsets.begin(), + object_with_metadata->metadata->part_offsets.end()); + if (!format_filter_info) format_filter_info = std::make_shared(); } From b2df5594d07b12b2a54ad8598a78f7dc0559a902 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 03:58:52 +0300 Subject: [PATCH 37/39] Parquet v3: configurable read alignment (fixed-grid stride + min-segment guard) Generalizes the experimental multipart read alignment so it can be tuned and used without probing per-file layout. * input_format_parquet_read_alignment_bytes (UInt64, 0=off): align coalesced reads to a fixed byte grid (e.g. the writer's multipart part size - 10 MiB for delta-rs, 64 MiB for Spark/S3A) so no read straddles a multiple of it. Needs no GetObjectAttributes / identity cache, so alignment can be measured in isolation. * input_format_parquet_read_alignment_min_bytes (UInt64, default 1 MiB): anti-fragmentation guard - don't cut a read at a boundary when the aligned segment would be smaller than this; allow the straddle instead of emitting a tiny extra request. The Prefetcher derives the boundary window from the real per-file part offsets when known (input_format_parquet_align_reads_to_multipart_boundaries), else from the fixed stride; the min-bytes guard disables the cut for tiny segments. New ProfileEvent ParquetPrefetcherAlignmentSkippedSmall counts those skips (ParquetPrefetcherPartAlignedTasks already counts constrained tasks). Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 1 + src/Core/FormatFactorySettings.h | 6 ++++ src/Core/SettingsChangesHistory.cpp | 2 ++ src/Formats/FormatFactory.cpp | 2 ++ src/Formats/FormatSettings.h | 2 ++ .../Formats/Impl/Parquet/Prefetcher.cpp | 34 +++++++++++++++---- .../Formats/Impl/Parquet/Prefetcher.h | 6 ++++ .../Formats/Impl/Parquet/ReadCommon.h | 11 +++++- .../Impl/ParquetV3BlockInputFormat.cpp | 4 +++ 9 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 2c4c7f7fb467..44eae6ce1afe 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1455,6 +1455,7 @@ The server successfully detected this situation and will download merged part fr M(ParquetPrefetcherReadEntireFile, "The total number of read with ReadMode::EntireFileIsInMemory by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherServedFromRetainedTail, "The number of ranges (e.g. Column/Offset Index) served from the retained footer tail by DB::Parquet::Prefetcher without issuing a read", ValueType::Number) \ M(ParquetPrefetcherPartAlignedTasks, "The number of read tasks whose coalescing was constrained to a single S3 multipart-upload part boundary by DB::Parquet::Prefetcher", ValueType::Number) \ + M(ParquetPrefetcherAlignmentSkippedSmall, "The number of times DB::Parquet::Prefetcher skipped read alignment because the aligned segment would be smaller than the configured minimum (anti-fragmentation)", ValueType::Number) \ M(ParquetRowsFilterExpression, "The total number of rows that were passed through filter", ValueType::Number) \ M(ParquetColumnsFilterExpression, "The total number of columns that were passed through filter", ValueType::Number) \ M(FilterTransformPassedRows, "Number of rows that passed the filter in the query", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 4a3c4cc388b5..4d97af865954 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -218,6 +218,12 @@ Experimental. When a Parquet column is single-valued over some data pages but no )", 0) \ DECLARE(Bool, input_format_parquet_align_reads_to_multipart_boundaries, false, R"( Experimental. Align coalesced Parquet read requests to the boundaries of the object's S3 multipart-upload parts, so a single read never straddles two parts (an AWS best practice). Requires the per-file multipart layout, learned via GetObjectAttributes and cached (see the object-storage identity cache); has no effect for single-part objects or stores that don't expose part info. Disabled by default. +)", 0) \ + DECLARE(UInt64, input_format_parquet_read_alignment_bytes, 0, R"( +Experimental. Align coalesced Parquet read requests to a fixed byte grid of this size, so no read straddles a multiple of it. Set to the writer's multipart part size (e.g. 10Mi for delta-rs, 64Mi for Spark/S3A) to keep reads within single parts without probing per-file layout. 0 disables. When `input_format_parquet_align_reads_to_multipart_boundaries` is enabled and the real per-file part layout is known, that takes precedence over this fixed grid. +)", 0) \ + DECLARE(UInt64, input_format_parquet_read_alignment_min_bytes, 1048576, R"( +Experimental. Anti-fragmentation guard for Parquet read alignment (`input_format_parquet_read_alignment_bytes` / `input_format_parquet_align_reads_to_multipart_boundaries`): do not cut a read at a boundary when the resulting aligned segment would be smaller than this many bytes; allow the straddle instead of emitting a tiny extra request. Default 1 MiB. )", 0) \ DECLARE(Double, input_format_parquet_prefetch_bandwidth_hide_seconds, 0, R"( Read back-pressure for the Parquet v3 reader. When greater than zero, stop prefetching more compressed data pages ahead of decoding once the in-flight compressed bytes exceed this many seconds' worth of the measured read throughput (i.e. once the storage link is kept busy). Prevents buffering compressed data far beyond what bandwidth can consume. 0 disables the back-pressure (compressed prefetch is then bounded only by its memory budget). diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index b6d3b4919107..bcf70926f8f1 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -46,6 +46,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_use_column_index_for_constant_columns", false, false, "New setting: load the Parquet Column Index for read columns without a predicate so the constant-column optimization can skip data pages that are single-valued over a row subgroup. Disabled by default (costs a small extra read)."}, {"input_format_parquet_fill_constant_pages", false, false, "New experimental setting: fill single-valued Parquet data pages from per-page Column Index statistics instead of decoding them, even when only part of a row subgroup is constant. Disabled by default."}, {"input_format_parquet_align_reads_to_multipart_boundaries", false, false, "New experimental setting: align coalesced Parquet read requests to S3 multipart-upload part boundaries so a single read never straddles two parts. Disabled by default."}, + {"input_format_parquet_read_alignment_bytes", 0, 0, "New experimental setting: align coalesced Parquet reads to a fixed byte grid (e.g. the writer's multipart part size) without probing per-file layout. 0 disables."}, + {"input_format_parquet_read_alignment_min_bytes", 1048576, 1048576, "New experimental setting: anti-fragmentation guard for Parquet read alignment - do not split a read at a boundary when the aligned segment would be smaller than this. Default 1 MiB."}, {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, {"output_format_image_width", 1024, 1024, "New setting controlling the width of the output image for image output formats such as PNG."}, diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index b982d53628dc..066104e88e9a 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -223,6 +223,8 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.use_column_index_for_constant_columns = settings[Setting::input_format_parquet_use_column_index_for_constant_columns]; format_settings.parquet.fill_constant_pages = settings[Setting::input_format_parquet_fill_constant_pages]; format_settings.parquet.align_reads_to_multipart_boundaries = settings[Setting::input_format_parquet_align_reads_to_multipart_boundaries]; + format_settings.parquet.read_alignment_bytes = settings[Setting::input_format_parquet_read_alignment_bytes]; + format_settings.parquet.read_alignment_min_bytes = settings[Setting::input_format_parquet_read_alignment_min_bytes]; format_settings.parquet.prefetch_bandwidth_hide_seconds = settings[Setting::input_format_parquet_prefetch_bandwidth_hide_seconds]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 0878c942fc4f..8eda668a47a7 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -353,6 +353,8 @@ struct FormatSettings bool use_column_index_for_constant_columns = false; bool fill_constant_pages = false; bool align_reads_to_multipart_boundaries = false; + UInt64 read_alignment_bytes = 0; + UInt64 read_alignment_min_bytes = 1048576; double prefetch_bandwidth_hide_seconds = 0; bool enable_json_parsing = true; diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index 4a1837f74b8a..bd521e85a102 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -26,6 +26,7 @@ namespace ProfileEvents extern const Event ParquetPrefetcherReadEntireFile; extern const Event ParquetPrefetcherServedFromRetainedTail; extern const Event ParquetPrefetcherPartAlignedTasks; + extern const Event ParquetPrefetcherAlignmentSkippedSmall; } namespace DB::Parquet @@ -36,6 +37,8 @@ void Prefetcher::init(ReadBuffer * reader_, const ReadOptions & options, FormatP min_bytes_for_seek = options.min_bytes_for_seek; bytes_per_read_task = options.bytes_per_read_task; multipart_part_offsets = options.multipart_part_offsets; + read_alignment_stride = options.read_alignment_stride; + read_alignment_min_bytes = options.read_alignment_min_bytes; parser_shared_resources = parser_shared_resources_; determineReadModeAndFileSize(reader_, options); range_sets.resize(1); @@ -367,18 +370,35 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, size_t end_idx = range_idx + 1; size_t total_length_of_covered_ranges = end_offset - start_offset; - /// EXPERIMENTAL part-boundary alignment: keep this coalesced task within a single S3 multipart - /// part, so one read never straddles a part boundary (an AWS best practice). Constrain coalescing - /// to [part_lo, part_hi) - the part containing the initial requested range's start. This only - /// limits *coalescing* across a boundary; a single requested range larger than a part is left as - /// is (would require splitRange). Empty offsets -> no constraint. + /// EXPERIMENTAL read alignment: keep this coalesced task within a single boundary window + /// [part_lo, part_hi) - the window containing the initial requested range's start - so one read + /// never straddles a boundary (an AWS best practice for multipart-uploaded objects). Boundaries + /// come from the real per-file multipart layout (multipart_part_offsets) when known, else from a + /// fixed grid (read_alignment_stride). This only limits *coalescing* across a boundary; a single + /// requested range larger than a window is left as is (would require splitRange). size_t part_lo = 0; size_t part_hi = std::numeric_limits::max(); + bool alignment_active = false; if (!multipart_part_offsets.empty()) { auto it = std::upper_bound(multipart_part_offsets.begin(), multipart_part_offsets.end(), start_offset); part_hi = (it == multipart_part_offsets.end()) ? file_size : *it; part_lo = (it == multipart_part_offsets.begin()) ? 0 : *std::prev(it); + alignment_active = true; + } + else if (read_alignment_stride > 0) + { + part_lo = start_offset / read_alignment_stride * read_alignment_stride; + part_hi = part_lo + read_alignment_stride; + alignment_active = true; + } + + /// Anti-fragmentation: if the aligned segment ahead of the initial range would be smaller than + /// read_alignment_min_bytes, cutting here would emit a tiny read - allow the straddle instead. + if (alignment_active && read_alignment_min_bytes > 0 && part_hi - start_offset < read_alignment_min_bytes) + { + alignment_active = false; + ProfileEvents::increment(ProfileEvents::ParquetPrefetcherAlignmentSkippedSmall); } bool part_boundary_constrained = false; @@ -392,7 +412,7 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, !r.request->allow_incidental_read.load(std::memory_order_relaxed)) // range wants to be coalesced break; - if (r.start < part_lo) // would cross below this part's boundary + if (alignment_active && r.start < part_lo) // would cross below this boundary window { part_boundary_constrained = true; break; @@ -433,7 +453,7 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, !r.request->allow_incidental_read.load(std::memory_order_relaxed)) break; - if (r.end > part_hi) // would cross above this part's boundary + if (alignment_active && r.end > part_hi) // would cross above this boundary window { part_boundary_constrained = true; break; diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index f33878f258b4..af807a2f2675 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -195,8 +195,14 @@ class Prefetcher /// Cumulative start offsets of the object's S3 multipart-upload parts, used to keep coalesced /// read tasks within a single part. Empty = no alignment. Set once in init(), read-only after. + /// Takes precedence over read_alignment_stride when non-empty. std::vector multipart_part_offsets; + /// Fixed boundary grid (bytes) used for read alignment when multipart_part_offsets is empty. + /// 0 = off. And the anti-fragmentation min aligned-segment size. Set in init(), read-only after. + size_t read_alignment_stride = 0; + size_t read_alignment_min_bytes = 0; + /// Tail chunk retained by retainTail() to serve fully-contained ranges (Column/Offset Index) /// without a second read. Written once before any prefetching, read-only afterwards. /// [retained_tail_start, retained_tail_end) are file offsets; empty range == nothing retained. diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.h b/src/Processors/Formats/Impl/Parquet/ReadCommon.h index b55954f0ee5d..a8dfc06ac646 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.h +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.h @@ -53,8 +53,17 @@ struct ReadOptions /// [multipart_part_offsets[i], multipart_part_offsets[i+1])). Learned from GetObjectAttributes /// (see ObjectStorageIdentityCache) and used to align coalesced read tasks to part boundaries so /// a single read never straddles two parts. Empty = unknown / single-part -> no alignment. - /// EXPERIMENTAL: used to measure the effect of part-boundary-aligned reads. + /// EXPERIMENTAL: used to measure the effect of part-boundary-aligned reads. Takes precedence over + /// read_alignment_stride when non-empty. std::vector multipart_part_offsets; + + /// Fixed boundary grid (bytes) for read alignment when the real per-file part layout is unknown: + /// no coalesced read straddles a multiple of this. 0 = off. EXPERIMENTAL. + size_t read_alignment_stride = 0; + + /// Anti-fragmentation guard: don't cut a read at an alignment boundary if the resulting aligned + /// segment would be smaller than this many bytes (allow the straddle instead of a tiny read). + size_t read_alignment_min_bytes = 0; }; /// Estimate the serialized size of a parquet FileMetaData footer, to size the initial tail read. diff --git a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp index df20d60cd7a7..e661e3935f2b 100644 --- a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp +++ b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp @@ -74,6 +74,10 @@ ParquetV3BlockInputFormat::ParquetV3BlockInputFormat( object_with_metadata->metadata->part_offsets.begin(), object_with_metadata->metadata->part_offsets.end()); + /// Fixed-grid alignment (no per-file layout needed) + the anti-fragmentation min-segment guard. + read_options.read_alignment_stride = read_options.format.parquet.read_alignment_bytes; + read_options.read_alignment_min_bytes = read_options.format.parquet.read_alignment_min_bytes; + if (!format_filter_info) format_filter_info = std::make_shared(); } From 1b1238f9cc4cd5e4153e9dfec4be633bee091fdb Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 09:52:27 +0300 Subject: [PATCH 38/39] Fix build: initialize ObjectMetadata::part_offsets + missing decimal include Two compile fixes surfaced by a local build: * The new `ObjectMetadata::part_offsets` field (added with the object-storage identity cache) broke designated-initializer sites under `-Werror,-Wmissing-designated-field-initializers`. Add `.part_offsets = {}` to the `ObjectMetadata{...}` initializers in the S3, HDFS and Azure object storages. * `IcebergWrites.cpp` uses `getDecimalScale` (for the Time64 branch) but did not include ``, where it is declared - add the include. Signed-off-by: UnamedRus --- .../ObjectStorages/AzureBlobStorage/AzureObjectStorage.cpp | 2 ++ .../DiskObjectStorage/ObjectStorages/HDFS/HDFSObjectStorage.cpp | 1 + .../DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp | 2 ++ src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergWrites.cpp | 1 + 4 files changed, 6 insertions(+) diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/AzureBlobStorage/AzureObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/AzureBlobStorage/AzureObjectStorage.cpp index aac0ef6a094a..5c1aa825040a 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/AzureBlobStorage/AzureObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/AzureBlobStorage/AzureObjectStorage.cpp @@ -107,6 +107,7 @@ class AzureIteratorAsync final : public IObjectStorageIteratorAsync .etag = blob.Details.ETag.ToString(), .tags = {}, .attributes = {}, + .part_offsets = {}, })); } @@ -216,6 +217,7 @@ void AzureObjectStorage::listObjects(const std::string & path, RelativePathsWith .etag = blob.Details.ETag.ToString(), .tags = {}, .attributes = {}, + .part_offsets = {}, })); } diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/HDFS/HDFSObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/HDFS/HDFSObjectStorage.cpp index e071b25f6b0f..82c8b2779376 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/HDFS/HDFSObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/HDFS/HDFSObjectStorage.cpp @@ -269,6 +269,7 @@ void HDFSObjectStorage::listObjects(const std::string & path, RelativePathsWithM .etag = {}, .tags = {}, .attributes = {}, + .part_offsets = {}, })); } diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 3862c9ed4d56..e3b8ab577f35 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -201,6 +201,7 @@ class S3IteratorAsync final : public IObjectStorageIteratorAsync .etag = object.GetETag(), .tags = {}, .attributes = {}, + .part_offsets = {}, }; if (with_tags) metadata.tags = S3::getObjectTags(*client, request->GetBucket(), object.GetKey()); @@ -382,6 +383,7 @@ void S3ObjectStorage::listObjects(const std::string & path, RelativePathsWithMet .etag = object.GetETag(), .tags = {}, .attributes = {}, + .part_offsets = {}, })); if (max_keys) diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergWrites.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergWrites.cpp index 5bc1fe22fbce..7a267e3b2b6d 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergWrites.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergWrites.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include From 8249162d122393c72ee989b8a25e0d501b45d224 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Sat, 8 Aug 2026 09:52:27 +0300 Subject: [PATCH 39/39] Parquet v3: hedged reads to cut S3 GET tail latency (Phase A) Standard S3 GET has a fat tail (p50 ~5 ms vs p99 100+ ms), so a single slow read on the critical path stalls a decode task (visible as large ParquetFetchWaitTimeMicroseconds). Hedging: if a read a consumer is blocked on doesn't finish within a threshold, issue a duplicate read and use whichever returns first (S3 GET is idempotent). Phase A (this commit) runs the hedge synchronously on the already-blocked consumer thread: getRangeData waits up to the threshold via a new CompletionNotification::wait_for, then reads the same range into task->hedge_buf and serves from it, notifying so sharers wake. This has no async-lifetime hazard - the hedge finishes before the PrefetchHandle is released. A fully async race (Phase B) is deferred: it would need decreaseTaskRefcount to defer freeing hedge_buf until an in-flight hedge finishes (the refcount/Deallocated path assumes a single reader), which risks a use-after-free if done naively. Scope: remote (RandomRead) reads only, no larger than a size cap (latency, not throughput), one hedge per task, bounded by a concurrency cap (cost control). Settings (all experimental, off by default): * input_format_parquet_hedged_read_threshold_ms (0 = off) * input_format_parquet_hedged_read_max_bytes (default 4 MiB) * input_format_parquet_hedged_read_max_inflight (default 4) ProfileEvents: ParquetPrefetcherHedgedReads, ParquetPrefetcherHedgedWins. Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 2 + src/Core/FormatFactorySettings.h | 9 +++ src/Core/SettingsChangesHistory.cpp | 3 + src/Formats/FormatFactory.cpp | 3 + src/Formats/FormatSettings.h | 3 + .../Formats/Impl/Parquet/Prefetcher.cpp | 68 ++++++++++++++++++- .../Formats/Impl/Parquet/Prefetcher.h | 28 +++++++- .../Formats/Impl/Parquet/ReadCommon.cpp | 38 +++++++++++ .../Formats/Impl/Parquet/ReadCommon.h | 12 ++++ .../Impl/ParquetV3BlockInputFormat.cpp | 5 ++ 10 files changed, 169 insertions(+), 2 deletions(-) diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 44eae6ce1afe..06d63fd7b525 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1456,6 +1456,8 @@ The server successfully detected this situation and will download merged part fr M(ParquetPrefetcherServedFromRetainedTail, "The number of ranges (e.g. Column/Offset Index) served from the retained footer tail by DB::Parquet::Prefetcher without issuing a read", ValueType::Number) \ M(ParquetPrefetcherPartAlignedTasks, "The number of read tasks whose coalescing was constrained to a single S3 multipart-upload part boundary by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherAlignmentSkippedSmall, "The number of times DB::Parquet::Prefetcher skipped read alignment because the aligned segment would be smaller than the configured minimum (anti-fragmentation)", ValueType::Number) \ + M(ParquetPrefetcherHedgedReads, "The number of hedged (duplicate) reads issued by DB::Parquet::Prefetcher to cut read tail latency", ValueType::Number) \ + M(ParquetPrefetcherHedgedWins, "The number of hedged reads that produced the result used (beat or replaced the primary read) in DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetRowsFilterExpression, "The total number of rows that were passed through filter", ValueType::Number) \ M(ParquetColumnsFilterExpression, "The total number of columns that were passed through filter", ValueType::Number) \ M(FilterTransformPassedRows, "Number of rows that passed the filter in the query", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 4d97af865954..b870d36f1566 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -224,6 +224,15 @@ Experimental. Align coalesced Parquet read requests to a fixed byte grid of this )", 0) \ DECLARE(UInt64, input_format_parquet_read_alignment_min_bytes, 1048576, R"( Experimental. Anti-fragmentation guard for Parquet read alignment (`input_format_parquet_read_alignment_bytes` / `input_format_parquet_align_reads_to_multipart_boundaries`): do not cut a read at a boundary when the resulting aligned segment would be smaller than this many bytes; allow the straddle instead of emitting a tiny extra request. Default 1 MiB. +)", 0) \ + DECLARE(UInt64, input_format_parquet_hedged_read_threshold_ms, 0, R"( +Experimental. Tail-latency mitigation for the Parquet v3 reader on remote object storage: if a read a query is blocked on has not completed within this many milliseconds, issue a duplicate (hedged) request and use whichever returns first. Cuts the S3 GET p99 tail at the cost of a few extra requests. 0 disables. Only reads no larger than `input_format_parquet_hedged_read_max_bytes` are hedged, and at most `input_format_parquet_hedged_read_max_inflight` hedges run at once. +)", 0) \ + DECLARE(UInt64, input_format_parquet_hedged_read_max_bytes, 4194304, R"( +Experimental. Only hedge Parquet reads (see `input_format_parquet_hedged_read_threshold_ms`) no larger than this - hedging targets latency of small/critical reads, not throughput of large coalesced reads. 0 = no size limit. Default 4 MiB. +)", 0) \ + DECLARE(UInt64, input_format_parquet_hedged_read_max_inflight, 4, R"( +Experimental. Cap on concurrent hedged Parquet reads (see `input_format_parquet_hedged_read_threshold_ms`), so a slow region cannot double all traffic. Default 4. )", 0) \ DECLARE(Double, input_format_parquet_prefetch_bandwidth_hide_seconds, 0, R"( Read back-pressure for the Parquet v3 reader. When greater than zero, stop prefetching more compressed data pages ahead of decoding once the in-flight compressed bytes exceed this many seconds' worth of the measured read throughput (i.e. once the storage link is kept busy). Prevents buffering compressed data far beyond what bandwidth can consume. 0 disables the back-pressure (compressed prefetch is then bounded only by its memory budget). diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index bcf70926f8f1..8c6fd5578f3e 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -48,6 +48,9 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_align_reads_to_multipart_boundaries", false, false, "New experimental setting: align coalesced Parquet read requests to S3 multipart-upload part boundaries so a single read never straddles two parts. Disabled by default."}, {"input_format_parquet_read_alignment_bytes", 0, 0, "New experimental setting: align coalesced Parquet reads to a fixed byte grid (e.g. the writer's multipart part size) without probing per-file layout. 0 disables."}, {"input_format_parquet_read_alignment_min_bytes", 1048576, 1048576, "New experimental setting: anti-fragmentation guard for Parquet read alignment - do not split a read at a boundary when the aligned segment would be smaller than this. Default 1 MiB."}, + {"input_format_parquet_hedged_read_threshold_ms", 0, 0, "New experimental setting: hedge a Parquet read (issue a duplicate, take the winner) if it exceeds this many ms, to cut the S3 GET tail latency. 0 disables."}, + {"input_format_parquet_hedged_read_max_bytes", 4194304, 4194304, "New experimental setting: only hedge Parquet reads no larger than this many bytes. Default 4 MiB."}, + {"input_format_parquet_hedged_read_max_inflight", 4, 4, "New experimental setting: cap on concurrent hedged Parquet reads. Default 4."}, {"analyzer_compatibility_allow_non_aggregate_in_having", false, false, "New compatibility setting. When enabled, the new analyzer mimics the legacy `HAVING`-to-`WHERE` rewrite for non-aggregate AND-conjuncts instead of raising `NOT_AN_AGGREGATE`."}, {"reserve_memory", 0, 0, "New setting to reserve memory for specific workload before starting a query."}, {"output_format_image_width", 1024, 1024, "New setting controlling the width of the output image for image output formats such as PNG."}, diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index 066104e88e9a..bc122e036ea6 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -225,6 +225,9 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.align_reads_to_multipart_boundaries = settings[Setting::input_format_parquet_align_reads_to_multipart_boundaries]; format_settings.parquet.read_alignment_bytes = settings[Setting::input_format_parquet_read_alignment_bytes]; format_settings.parquet.read_alignment_min_bytes = settings[Setting::input_format_parquet_read_alignment_min_bytes]; + format_settings.parquet.hedged_read_threshold_ms = settings[Setting::input_format_parquet_hedged_read_threshold_ms]; + format_settings.parquet.hedged_read_max_bytes = settings[Setting::input_format_parquet_hedged_read_max_bytes]; + format_settings.parquet.hedged_read_max_inflight = settings[Setting::input_format_parquet_hedged_read_max_inflight]; format_settings.parquet.prefetch_bandwidth_hide_seconds = settings[Setting::input_format_parquet_prefetch_bandwidth_hide_seconds]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 8eda668a47a7..f7139a07fa0e 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -355,6 +355,9 @@ struct FormatSettings bool align_reads_to_multipart_boundaries = false; UInt64 read_alignment_bytes = 0; UInt64 read_alignment_min_bytes = 1048576; + UInt64 hedged_read_threshold_ms = 0; + UInt64 hedged_read_max_bytes = 4194304; + UInt64 hedged_read_max_inflight = 4; double prefetch_bandwidth_hide_seconds = 0; bool enable_json_parsing = true; diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index bd521e85a102..8c170d1318fb 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -27,6 +27,8 @@ namespace ProfileEvents extern const Event ParquetPrefetcherServedFromRetainedTail; extern const Event ParquetPrefetcherPartAlignedTasks; extern const Event ParquetPrefetcherAlignmentSkippedSmall; + extern const Event ParquetPrefetcherHedgedReads; + extern const Event ParquetPrefetcherHedgedWins; } namespace DB::Parquet @@ -39,6 +41,9 @@ void Prefetcher::init(ReadBuffer * reader_, const ReadOptions & options, FormatP multipart_part_offsets = options.multipart_part_offsets; read_alignment_stride = options.read_alignment_stride; read_alignment_min_bytes = options.read_alignment_min_bytes; + hedged_read_threshold_ms = options.hedged_read_threshold_ms; + hedged_read_max_bytes = options.hedged_read_max_bytes; + hedged_read_max_inflight = options.hedged_read_max_inflight; parser_shared_resources = parser_shared_resources_; determineReadModeAndFileSize(reader_, options); range_sets.resize(1); @@ -520,6 +525,7 @@ void Prefetcher::decreaseTaskRefcount(Task * task, size_t amount) { task->buf = {}; task->cached_region.reset(); + task->hedge_buf = {}; // hedge (if any) is synchronous and already finished by now } } @@ -535,6 +541,44 @@ void Prefetcher::scheduleTask(Task * task) }); } +bool Prefetcher::hedgeReadSync(Task * task) +{ + /// Wait up to the threshold for the primary; if it finishes in time, no hedge is needed. + if (task->completion.wait_for(hedged_read_threshold_ms)) + return false; + + /// Primary is slow. Only one consumer hedges a given task. + bool expected = false; + if (!task->hedge_started.compare_exchange_strong(expected, true)) + return false; // someone else is hedging (or already did) -> fall back to completion.wait() + + /// Cost cap: bound concurrent hedges so a slow region can't double all traffic. + if (hedges_inflight.fetch_add(1) >= hedged_read_max_inflight) + { + hedges_inflight.fetch_sub(1); + return false; + } + + ProfileEvents::increment(ProfileEvents::ParquetPrefetcherHedgedReads); + bool ok = false; + try + { + /// Read the same range on this (already-blocked) consumer thread, racing the primary. + task->hedge_buf.resize(task->length); + readSync(task->hedge_buf.data(), task->length, task->offset); + task->hedge_winner.store(2, std::memory_order_release); + task->completion.notify(); // wake sharers waiting on the primary; they'll use hedge_buf + ProfileEvents::increment(ProfileEvents::ParquetPrefetcherHedgedWins); + ok = true; + } + catch (...) + { + task->hedge_buf = {}; // hedge failed; fall back to the primary read + } + hedges_inflight.fetch_sub(1); + return ok; +} + std::span Prefetcher::getRangeData(const PrefetchHandle & request) { const RequestState * req = request.request; @@ -553,12 +597,33 @@ std::span Prefetcher::getRangeData(const PrefetchHandle & request) if (s == Task::State::Running) // (not `else`, the runTask above may return Running) { - task->completion.wait(); + /// Hedging: if the primary read hasn't finished within the threshold, issue a duplicate + /// read to cut the S3 GET tail. Eligible only for real (non-cached) remote reads no + /// larger than the size cap. hedgeReadSync waits up to the threshold itself. + bool served_by_hedge = false; + if (hedged_read_threshold_ms > 0 && read_mode == ReadMode::RandomRead + && !task->cached_region.has_value() && task->length > 0 + && (hedged_read_max_bytes == 0 || task->length <= hedged_read_max_bytes)) + { + served_by_hedge = hedgeReadSync(task); + } + + if (!served_by_hedge) + task->completion.wait(); s = task->state.load(); } ProfileEvents::increment(ProfileEvents::ParquetFetchWaitTimeMicroseconds, wait_time.elapsedMicroseconds()); } + + /// If a hedge produced the data, use it regardless of the primary's state (which may still be + /// Running, or even Exception if the primary failed but the hedge succeeded). + if (task->hedge_winner.load(std::memory_order_acquire) == 2) + { + chassert(req->task_offset + req->length <= task->hedge_buf.size()); + return std::span(task->hedge_buf.data() + req->task_offset, req->length); + } + if (s == Task::State::Exception) rethrowException(task); chassert(s == Task::State::Done); @@ -651,6 +716,7 @@ Prefetcher::Task::State Prefetcher::runTask(Task * task) chassert(s == Task::State::Deallocated); task->buf = {}; task->cached_region.reset(); + task->hedge_buf = {}; } task->completion.notify(); diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index af807a2f2675..91cda24ffa11 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -161,9 +161,18 @@ class Prefetcher std::atomic state {State::Scheduled}; /// How many RequestState-s in HasTask state point to this Task. std::atomic refcount {}; - /// Notified when the state changes from Running to Done or Exception. + /// Notified when the state changes from Running to Done or Exception (by the primary read, or + /// by a hedge read - whichever finishes first). CompletionNotification completion; std::exception_ptr exception; + + /// Hedging (tail-latency mitigation): a second read of the same range, raced against the + /// primary. Whichever finishes first CAS-wins `hedge_winner` (1 = primary, 2 = hedge) and + /// notifies `completion`; getRangeData then returns the winner's buffer. `hedge_buf` holds + /// the hedge read; `hedge_started` guards launching it at most once. + PaddedPODArray hedge_buf; + std::atomic hedge_winner {0}; + std::atomic hedge_started {false}; }; enum class ReadMode @@ -203,6 +212,13 @@ class Prefetcher size_t read_alignment_stride = 0; size_t read_alignment_min_bytes = 0; + /// Hedged reads (tail-latency mitigation). Set in init(), read-only after. hedges_inflight is the + /// live count, bounded by hedged_read_max_inflight. + size_t hedged_read_threshold_ms = 0; + size_t hedged_read_max_bytes = 0; + size_t hedged_read_max_inflight = 0; + std::atomic hedges_inflight {0}; + /// Tail chunk retained by retainTail() to serve fully-contained ranges (Column/Offset Index) /// without a second read. Written once before any prefetching, read-only afterwards. /// [retained_tail_start, retained_tail_end) are file offsets; empty range == nothing retained. @@ -239,6 +255,16 @@ class Prefetcher void scheduleTask(Task * task); Task::State runTask(Task * task); [[noreturn]] void rethrowException(Task * task); + + /// Issue a duplicate (hedged) read of `task`'s range when the primary is slow, to cut tail + /// latency. Phase A: the read runs synchronously on the calling (already-blocked) consumer + /// thread into task->hedge_buf; on success sets hedge_winner=2 and notifies so sharers wake. + /// Returns true if a usable hedge result was produced. At most once per task (hedge_started + /// guard), bounded by hedged_read_max_inflight, and only for real (non-cached) remote reads. + /// NOTE: a fully async race (Phase B) would need decreaseTaskRefcount to defer freeing + /// hedge_buf until an in-flight hedge finishes (the refcount/Deallocated path assumes one + /// reader) - deferred to avoid a use-after-free on hedge_buf. + bool hedgeReadSync(Task * task); }; /// Pins a pre-registered range that we may want to read. diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp b/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp index 736f8ec0ed9b..beedef04a9e8 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.cpp @@ -3,6 +3,8 @@ #include #include +#include + namespace DB::Parquet { @@ -58,6 +60,35 @@ void CompletionNotification::notify() futexWake(&val, INT32_MAX); } +bool CompletionNotification::wait_for(UInt64 timeout_ms) +{ + UInt32 n = val.load(std::memory_order_acquire); + if (n == NOTIFIED) + return true; + if (n == EMPTY) + { + if (!val.compare_exchange_strong(n, WAITING)) + { + if (n == NOTIFIED) + return true; + chassert(n == WAITING); + } + } + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (true) + { + n = val.load(); + if (n == NOTIFIED) + return true; + chassert(n == WAITING); + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) + return false; + const UInt64 remaining_ns = std::chrono::duration_cast(deadline - now).count(); + futexTimedWait(&val, WAITING, remaining_ns); // may wake spuriously / on partial timeout; loop re-checks + } +} + #else bool CompletionNotification::check() const @@ -77,6 +108,13 @@ void CompletionNotification::notify() promise.set_value(); } +bool CompletionNotification::wait_for(UInt64 timeout_ms) +{ + if (check()) + return true; + return future.wait_for(std::chrono::milliseconds(timeout_ms)) == std::future_status::ready; +} + #endif } diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.h b/src/Processors/Formats/Impl/Parquet/ReadCommon.h index a8dfc06ac646..98e150697b1b 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.h +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.h @@ -64,6 +64,14 @@ struct ReadOptions /// Anti-fragmentation guard: don't cut a read at an alignment boundary if the resulting aligned /// segment would be smaller than this many bytes (allow the straddle instead of a tiny read). size_t read_alignment_min_bytes = 0; + + /// Hedged reads (tail-latency mitigation): if a read a consumer is blocked on hasn't completed + /// within hedged_read_threshold_ms, issue a duplicate read and take whichever returns first. + /// 0 = off. Only reads no larger than hedged_read_max_bytes are hedged (latency, not throughput), + /// and at most hedged_read_max_inflight hedges run concurrently (cost cap). EXPERIMENTAL. + size_t hedged_read_threshold_ms = 0; + size_t hedged_read_max_bytes = 0; + size_t hedged_read_max_inflight = 0; }; /// Estimate the serialized size of a parquet FileMetaData footer, to size the initial tail read. @@ -268,6 +276,8 @@ class CompletionNotification public: bool check() const; void wait(); + /// Wait up to timeout_ms. Returns true if notified, false on timeout. + bool wait_for(UInt64 timeout_ms); void notify(); }; @@ -283,6 +293,8 @@ class CompletionNotification public: bool check() const; void wait(); + /// Wait up to timeout_ms. Returns true if notified, false on timeout. + bool wait_for(UInt64 timeout_ms); void notify(); }; diff --git a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp index e661e3935f2b..1a95bb8509f4 100644 --- a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp +++ b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp @@ -78,6 +78,11 @@ ParquetV3BlockInputFormat::ParquetV3BlockInputFormat( read_options.read_alignment_stride = read_options.format.parquet.read_alignment_bytes; read_options.read_alignment_min_bytes = read_options.format.parquet.read_alignment_min_bytes; + /// Hedged reads (tail-latency mitigation). + read_options.hedged_read_threshold_ms = read_options.format.parquet.hedged_read_threshold_ms; + read_options.hedged_read_max_bytes = read_options.format.parquet.hedged_read_max_bytes; + read_options.hedged_read_max_inflight = read_options.format.parquet.hedged_read_max_inflight; + if (!format_filter_info) format_filter_info = std::make_shared(); }