Skip to content

Read Parquet bloom filters in one request when a file has many row groups - #2526

Draft
ianton-ru wants to merge 4 commits into
antalya-26.6from
feature/antalya-26.6/parquet-bloom-filter-coalesce
Draft

ianton-ru wants to merge 4 commits into
antalya-26.6from
feature/antalya-26.6/parquet-bloom-filter-coalesce

Conversation

@ianton-ru

Copy link
Copy Markdown

Separate GETs for each filter dominate cold reads from object storage. input_format_parquet_bloom_filter_coalesce_max_bytes merges contiguous filters, and reads a small file once when that is cheaper.

Changelog category (leave one):

  • Performance Improvement

Changelog entry (a user-readable short description of the changes that goes to CHANGELOG.md):

Read Parquet bloom filters in one request when a file has many row groups

Documentation entry for user-facing changes

ClickHouse can build Bloom-filter for Iceberg tables for queries with 'WHERE' condition not on key columns. For this ClickHouse reads metainfo for each row group from source files. In initial implementation it use single read operation for each row group, which is converted to separate GET request when data files are on object storage like S3. For large table it can produce thousands of requests. In customer test first request takes near 500 seconds, while second (when data are in local cache) less than 4 seconds.
This PR introduced reading metainfo for multiple row groups in single request, when possible. It can increase total traffic, but decrease number of requests, which can reduce time for query execution.

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful tests
  • Unit tests
  • Performance tests
  • Aarch64 tests
  • All with ASAN
  • All with TSAN
  • All with MSAN
  • All with UBSAN
  • All with Coverage
  • All Regression
  • Disable CI Cache

Regression jobs to run:

  • Fast suites (mostly <1h)
  • Aggregate Functions (2h)
  • Alter (1.5h)
  • Benchmark (30m)
  • CAS (content-addressed storage; Antalya only)
  • ClickHouse Keeper (1h)
  • Iceberg (2h)
  • LDAP (1h)
  • OAuth (5m)
  • Parquet (1.5h)
  • RBAC (1.5h)
  • SSL Server (1h)
  • S3 (2h)
  • S3 Export (2h)
  • Swarms (30m)
  • Tiered Storage (2h)

…oups.

Separate GETs for each filter dominate cold reads from object storage. `input_format_parquet_bloom_filter_coalesce_max_bytes` merges contiguous filters, and reads a small file once when that is cheaper.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T17:44:14.526259Z 7d0a392 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Workflow [PR], commit [7d0a392]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 348489a2c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 189 to 193
bool already_charged = false;
if (req->coalesce_group != 0 && task->length > memory_usage
&& task->coalesce_memory_charged.compare_exchange_strong(already_charged, true, std::memory_order_relaxed))
memory_usage = task->length;
handle->memory = MemoryUsageToken(memory_usage, diff);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the coalesced buffer charged while it remains pinned

When a header starts a coalesced task, its handle receives the one-time full-task charge, but processBloomFilterHeader immediately resets that header after splitting the data handle into block handles. Those children have no coalescing group and later charge only their tiny amplified ranges, while the task's buffer remains pinned by the other headers and blocks sharing it. With many files or 64 MiB clusters, the scheduler can therefore report almost no memory for multiple live coalesced buffers, bypass memory_high_watermark, and cause excessive memory use or OOMs; the full-task charge needs a lifetime tied to the shared task rather than the first handle.

Useful? React with 👍 / 👎.

Comment on lines +582 to +584
const auto & meta = row_group.columns[column_idx].meta->meta_data;
if (meta.__isset.bloom_filter_offset && meta.__isset.bloom_filter_length && meta.bloom_filter_length > 0)
++bloom_count;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count bloom filters that omit their optional length

For valid Parquet files that provide bloom_filter_offset but omit the optional bloom_filter_length, none of those filters contribute to bloom_count, so a file containing only such filters never enables coalescing. The existing hard-way path below explicitly derives their lengths and registers them with bloom_group, but that group remains zero here, leaving these files with the original per-filter requests; count usable offsets here and let the later length derivation supply their ranges.

Useful? React with 👍 / 👎.

Comment on lines +378 to +383
const bool same_group = initial_req->coalesce_group != 0 && r.request->coalesce_group == initial_req->coalesce_group;
const size_t gap_limit = (same_group && coalesce_group_across_gaps) ? coalesce_group_span_limit : min_bytes_for_seek;
const size_t span_limit = same_group ? std::max(coalesce_group_span_limit, bytes_per_read_task) : bytes_per_read_task;
if ((initial_req->coalesce_group != 0 && !same_group) ||
end_offset + gap_limit <= r.start ||
initial_offset + span_limit <= r.end ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the configured coalescing byte cap

When input_format_parquet_bloom_filter_coalesce_max_bytes is smaller than bytes_per_read_task—for example, a 1 MiB cap with a 1 MiB seek threshold, which makes bytes_per_read_task 4 MiB—this max permits grouped bloom-filter reads up to the larger ordinary task size. That contradicts the setting's documented role as the maximum bytes of one bloom-filter read and can unexpectedly multiply network and memory usage for deliberately small limits; grouped ranges should use the configured cap rather than raise it to the generic task size.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code change for this comment. With the default settings the std::max does not raise the cap.

bytes_per_read_task is min_bytes_for_seek * 4. On object storage that seek is 4 MiB, so a normal read task is 16 MiB. input_format_parquet_bloom_filter_coalesce_max_bytes defaults to 64 MiB, which is already larger, so a grouped bloom-filter read stops at 64 MiB as documented.

The example (a 1 MiB cap with a 1 MiB seek, so a 4 MiB task) is a non-default setting. In that case the grouped span follows the normal task size instead of the smaller cap. Filters farther apart than min_bytes_for_seek are still not merged: on the cluster path the gap limit stays min_bytes_for_seek. The whole-file path only runs when the file itself is not larger than the cap, so that read cannot grow past the file.

ianton-ru and others added 2 commits October 9, 2026 19:00
… buffer.

The bloom-filter header that starts the read is reset as soon as it is parsed, so charging that handle let the scheduler treat a live buffer as free.

Related: #2526
Co-authored-by: Cursor <cursoragent@cursor.com>
That field is optional. Filters that only store an offset were left out of coalescing, even though their length is already derived later.

Related: #2526
Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d0a392ea3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +588 to +595
if (!primitive_columns[column_idx].use_bloom_filter)
continue;
const auto & meta = row_group.columns[column_idx].meta->meta_data;
/// `bloom_filter_length` is optional (Parquet 2.10). An offset without it is still a
/// filter; the hard-way path below derives the length and registers that range.
const bool length_missing_or_positive = !meta.__isset.bloom_filter_length || meta.bloom_filter_length > 0;
if (meta.__isset.bloom_filter_offset && length_missing_or_positive)
++bloom_count;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude dictionary-filtered chunks from the whole-file count

When an eligible column is fully dictionary-encoded, the later initialization loop sets column.use_dictionary_filter and deliberately skips registering its bloom-filter ranges, but this preliminary count still includes the filter metadata. For a file under the configured limit, enough such chunks make whole_file true and place every range in the coalescing group, so the first dictionary-page request reads nearly the whole object even though no bloom filter will be used. Selective queries on dictionary-encoded files can therefore regress from small dictionary reads to a full-file transfer; base this heuristic on chunks that remain bloom-filter reads after dictionary-filter selection.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant