Skip to content

Parallelize reads from a single Parquet file in StorageFile, again - #104431

Open
alexey-milovidov wants to merge 86 commits into
masterfrom
revert-104359-revert-104251-parquet-single-file-parallelism
Open

Parallelize reads from a single Parquet file in StorageFile, again#104431
alexey-milovidov wants to merge 86 commits into
masterfrom
revert-104359-revert-104251-parquet-single-file-parallelism

Conversation

@alexey-milovidov

Copy link
Copy Markdown
Member

Reverts #104359

@clickhouse-gh

clickhouse-gh Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [0b62bd5]

Summary:

job_name test_name status info comment
Performance Comparison (arm_release, master_head, 3/6) FAIL Performance dashboard
insert_sequential_and_background_merges #0::old FAIL query history
insert_sequential_and_background_merges #0::new FAIL query history

AI Review

Summary

This PR reintroduces parallel reads for a single local Parquet file in StorageFile, adds the fail-close guards around bucketed reads and mixed-version *Cluster tasks, and adds focused regression coverage for the earlier cache / protocol / naming issues. Most of the prior correctness gaps look closed and the current CI is green, but there is still one supported reader mode where the new size estimator does not follow the actual logical column names, so I would not approve it yet.

Findings

⚠️ Majors

  • [src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp:570] collectLogicalPaths now normalizes Parquet maps only to the default Map subcolumn spelling (keys / values), but the reader also supports requesting the same footer shape as Array(Tuple(key, value)) via SchemaContext::MapTupleAsPlainTuple, which exposes m.key / m.value instead. In that supported mode requested_columns contains m.value, this estimator emits only m.values, and projectedCompressedBytes misses the heavy value chunks, so the single-file split stays disabled for exactly the queries this PR is supposed to fan out.
    Suggested fix: preserve both spellings here, or derive the logical map path from the same MapTupleAsPlainTuple / MapTuple choice SchemaConverter uses, and add a regression test with an explicit Array(Tuple(key String, value String)) schema.
Final Verdict

⚠️ One remaining major issue in the Parquet map-path normalization; needs another fix before approval.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.60% 86.60% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.90% 78.80% -0.10%

Changed lines: Changed C/C++ lines covered: 652/724 (90.06%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-not-for-changelog This PR should not be mentioned in the changelog label May 8, 2026
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Good: the newly added performance tests prevent this change:

Screenshot_20260510_123149

Comment thread src/Storages/StorageFile.cpp
When a single Parquet file is split into multiple bucketed sources by
`StorageFile` (the path re-introduced in this PR), the file-level count
cache must be bypassed: it is keyed by file path, so consulting or
writing it from the bucketed read path would have every source report
the file's full row count and multiply the result by the number of
buckets.

Addresses review feedback on PR #104431 asking for explicit regression
coverage of this invariant.
Comment thread src/Storages/StorageFile.cpp
Three gates on the bucketed single-file read path in `StorageFile`,
addressing the `clickbench_parquet_short` regression observed on ARM in
the CI of #104431 (#104431)
and an open review comment.

1. `parallelize_output_from_storages = 0` now disables the split. The
   per-bucket sources are exactly the kind of read parallelism the
   setting's contract is about, but the existing check fired only after
   the sources had been created. Review feedback from
   #104431 (comment).

2. `need_only_count` queries skip the split. They consult only the
   file's metadata, so splitting them across N sources just multiplies
   the metadata-parse cost N-fold without any read-side benefit. This
   was the largest single contributor to the `Q1` (`SELECT COUNT(*)`)
   regression in `clickbench_parquet_short`.

3. The Parquet splitter (`ParquetBucketSplitter::splitToBucketsByCount`)
   now requires each chunk to cover at least 8 row groups. For a file
   with a small number of row groups, parallelising across all available
   threads multiplies per-bucket metadata-parse and prefetcher-setup
   overhead without giving each source enough work to amortise it. Large
   files (many row groups) still get full parallelism.

Updates `02725_parquet_preserve_order.reference`: the 2-row-group test
file falls below the new row-group floor so the pipeline goes back to a
single `File` source followed by `Resize 1 → 2`, matching pre-#104251
behaviour.

CI report:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=78ecefef098eedd55ab0a0ce350082364c8c23be&name_0=PR&name_1=Performance%20Comparison%20%28arm_release%2C%20master_head%2C%201%2F6%29
@alexey-milovidov

Copy link
Copy Markdown
Member Author

@alexey-milovidov

Copy link
Copy Markdown
Member Author

@alexey-milovidov

Copy link
Copy Markdown
Member Author

From now on, we will be interested in accelerating those few queries that show the difference between the "single" and "partitioned" variants. If we can make it without degradations of other queries, we can merge this PR.

Comment thread src/Storages/StorageFile.cpp
alexey-milovidov and others added 2 commits May 15, 2026 08:05
… single-file split

This test pins the contract requested in
#104431 (comment): when
`parallelize_output_from_storages = 0` is set, the single-file Parquet
split path in `StorageFile` must not fan out into multiple per-bucket
sources, even if `max_threads > 1` and the file has enough row groups
to otherwise be split.

The existing `04230_parquet_single_file_parallel_count.sql` only
exercises `parallelize_output_from_storages = 1`. The previous
regression was specifically that `parallelize_output_from_storages = 0`
was ignored for this branch, so a dedicated test is needed to prevent
silent regressions.

The test creates a 40-row-group Parquet file (well above the
8-row-groups-per-chunk floor in `ParquetBucketSplitter`) and uses
`EXPLAIN PIPELINE` to assert that with `parallelize_output_from_storages
= 0` there is no `File ×` multiplier and no `Resize` step, and with
`parallelize_output_from_storages = 1` the file IS split (the pipeline
contains `File × N`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

@groeneai, three stateless amd_msan, WasmEdge failures here are the standard DB::SystemLogQueue<DB::QueryLogElement>::waitFlush Timeout exceeded (180 s) pattern:

  • 01939_network_receive_bytes_metrics
  • 02703_max_local_read_bandwidth
  • 03753_join_runtime_filter_dynamically_disable (tracked in #104925)

Same exception across all three: Timeout exceeded (180 s) while flushing system log 'DB::SystemLogQueue<DB::QueryLogElement>'. CIDB shows 12 hits on 2026-05-14 and 14 on 2026-05-13 across many unrelated PRs (and master), so this is not related to the parallel Parquet read path in this PR. Please investigate the underlying SystemLog flush hang and provide a fix in a separate PR.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=6956a6a1a05203b43c57d3e3fc259668147ea190&name_0=PR&name_1=Stateless%20tests%20%28amd_msan%2C%20WasmEdge%2C%20parallel%2C%201%2F2%29

@alexey-milovidov

Copy link
Copy Markdown
Member Author

@groeneai, the Install packages (amd_release) and Install packages (arm_release) failures are an infrastructure issue: yum localinstall is being given two RPMs of the same package at different version strings and refusing to install both:

Problem 1: cannot install both clickhouse-server-26.5.1.651-1.x86_64 and clickhouse-server-26.5.1.594-1.x86_64

The exact same failure mode hit PR #104445 on the same day with a different version pair (26.5.1.465 vs 26.5.1.641), so this is the install-test script picking up RPMs from two build jobs that produced different .594 / .651 (or .465 / .641) version strings. Not related to the Parquet changes in this PR. Please investigate and fix in a separate PR.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=6956a6a1a05203b43c57d3e3fc259668147ea190&name_0=PR&name_1=Install%20packages%20%28amd_release%29

`clickbench_parquet_short` on the synthetic 20-row-group file still
regressed 18-37% on `arm_release` even after the
`8-row-groups-per-chunk` floor in `ParquetBucketSplitter`: 20/8 = 2
chunks, and each extra bucket added ~1-3 ms of per-source setup
(metadata parse, prefetcher init, schema conversion, hyperrectangle
build, bloom-filter init) on top of the ~5 ms baseline. For "short"
queries on small files the parallelism win does not pay for that
fixed cost.

Bump the floor to 16. The synthetic test now reads with a single
source (20/16 = 1 chunk), while `04238_parquet_single_file_parallelize_output_off`
still exercises a 2-chunk split (40/16 = 2) and a real `hits.parquet`
(hundreds of row groups) still fans out up to `max_threads`.

CI report:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=5f02d3d3200c9d6572d8848eb639c034a0fc0c62&name_0=PR&name_1=Performance%20Comparison%20%28arm_release%2C%20master_head%2C%201%2F6%29
PR: #104431

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor

@alexey-milovidov — confirmed, all three failures (01939_network_receive_bytes_metrics, 02703_max_local_read_bandwidth, 03753_join_runtime_filter_dynamically_disable) are the chronic DB::SystemLogQueue<DB::QueryLogElement>::waitFlush 180s family unrelated to the parallel Parquet read path in this PR.

Status: root cause identified, short-term fix already merged

CIDB verification — WasmEdge MSan SystemLog flush timeouts on master, hourly (UTC):

hour master PR
2026-05-15 07:00 8 9
2026-05-15 08:00 0 9
2026-05-15 09:00 0 9
2026-05-15 10:00 0 1
2026-05-15 13:00 0 2
2026-05-15 14:00 0 1

Master goes to zero immediately after #104971 merges at 08:57Z. PR branches predate the fix and still hit it (expected — branches don't auto-rebase). Failure family count over 14 days: 14 distinct test names, ~720 hits across 200+ PRs — the 3 hits on this PR are part of that.

For this PR: the head commit 6956a6a1a05 is from 2026-05-13 (two days before the fix landed). A rebase onto current master + re-run should clear the 3 amd_msan, WasmEdge failures. The Install packages (amd_release/arm_release) RPM-version-mismatch is a separate infrastructure issue and is tracked in our internal queue.

Let me know if you'd like me to file a follow-up issue once Algunenano re-enables v3 in #105019 (to track whether the proper fix holds up), or treat the family as resolved once the master baseline stays clean for 48-72h.

@groeneai (session cron:clickhouse-ci-task-worker:20260515-221500)

alexey-milovidov and others added 2 commits May 17, 2026 00:14
After raising the per-chunk row-group floor to 16 in `ParquetBucketSplitter`,
the test file (`numbers(1000)` at row-group size 50 = 20 row groups)
stayed below the 2-chunk threshold (`32` row groups), so the bucketed
read path was no longer exercised — the test was no longer a regression
guard for the count-cache-on-bucketed-source bug it was added to pin.

Bump the file to 64 row groups (`numbers(3200)`) so the splitter
produces 4 chunks and the bucketed branch is actually hit. The asserted
counts are updated to match.

This addresses the open review comment on
#104431

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ache

The single-file Parquet split path in `StorageFile` parses the file's
metadata once in `ParquetBucketSplitter::splitToBucketsByCount` and then
the per-bucket `ParquetV3BlockInputFormat` sources each parse it again
in `getFileMetadata`. The splitter's parse went through Arrow's
`parquet::ReadMetaData` (uncacheable) while the source's parse went
through `Parquet::Reader::readFileMetaData` keyed in
`ParquetMetadataCache` by `(file_path, etag)` — so the two paths could
not share work. For "short" queries on a single Parquet file
(`clickbench_parquet_short` on the 828 MB synthetic ARM run), the
extra footer parse cost 1-1.5 ms on top of a 5-8 ms baseline — 12-30 %
of the runtime — and remained after raising the per-chunk row-group
floor to 16 because the splitter still parsed metadata before deciding
not to split.

Route the splitter through the same cache:

* `ParquetBucketSplitter::splitToBucketsByCount` now uses
  `Parquet::Reader::readFileMetaData` via a `Parquet::Prefetcher` (the
  same code path `NativeParquetSchemaReader::initializeIfNeeded` uses),
  producing a `parquet::format::FileMetaData` storable in
  `ParquetMetadataCache`.

* New `splitParquetFileWithCache` helper accepts a cache pointer and
  the same `(file_path, etag)` key the source builds (sub-second mtime
  + inode + size), populating the cache on miss and reusing the parse
  on hit.

* `ReadFromFile::initializePipeline` calls the cache-aware helper for
  Parquet, so the per-bucket sources hit the cache and skip the footer
  parse. The single-bucket case (no fan-out) benefits too: the
  surviving single source still hits the cache.

Net effect: cold cache → 1 parse total (same as master); warm cache →
0 parses on both paths.

CI report:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=3c70a3490a9353117f2ecc65ca1458dd7285378c&name_0=PR&name_1=Performance%20Comparison%20%28arm_release%2C%20master_head%2C%201%2F6%29
PR: #104431

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

@groeneai, two CI failures in this PR look unrelated to the Parquet changes — please verify and link any in-progress fix here, otherwise file as separate issues.

  1. Integration tests (amd_msan, 3/6)test_keeper_force_recovery/test.py::test_cluster_recovery — same 'This instance is not currently serving requests' mntr-output assertion that closed #78474 tracked. This PR touches StorageFile/Parquet only; Keeper is not in the changed paths.
  2. Unit tests (asan_ubsan, function_prop_fuzzer)FunctionsStress.stress and AllTests — tracked at #104877. The failing example is indexOf determinism, unrelated to Parquet.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=3c70a3490a9353117f2ecc65ca1458dd7285378c&name_0=PR

@groeneai

Copy link
Copy Markdown
Contributor

@alexey-milovidov — confirmed, both failures are unrelated to the Parquet changes in this PR. Cross-linking:

1. test_keeper_force_recovery/test.py::test_cluster_recovery — rare residual flake of closed #78474

The assertion shape ('This instance is not currently serving requests' vs the zk_version ... mntr response, gap between start_cluster()/leader handover and the test issuing mntr) matches the pattern that closed #78474 exactly.

CIDB (30-day): only 4 hits across 4 distinct PRs, 0 on master. No in-progress fix PR. Failures are sparse and re-distributed across unrelated PRs, so the underlying race is still present at very low rate (~0.1%) even after #78474's cleanup. Suggest reopening #78474 (or filing a follow-up) rather than a fresh issue — I can do that next worker run.

SELECT toStartOfDay(check_start_time) AS day, count() AS hits, count(DISTINCT pull_request_number) AS prs
FROM default.checks
WHERE check_start_time > now() - INTERVAL 30 DAY
  AND test_name LIKE '%test_keeper_force_recovery%cluster_recovery%'
  AND test_status IN ('FAIL','ERROR')
GROUP BY day ORDER BY day DESC
-- 2026-05-15: 1 hit (this PR)   2026-05-11: 1 (different shape)   2026-04-19: 2 hits

2. FunctionsStress.stress / AllTestsindexOf non-determinism (tracked in #104877 family)

The failing shape on 3c70a3490a is indexOf(materialize(CAST([... null-byte-padded strings ...] AS Array(...))), <IPv6-like String>) returning 1 vs 0 between two executions of the same query when arg {1} is made const. This is the same String↔FixedString / null-padding non-determinism root pattern as the rest of the #104877 chronic family.

indexOfAssumeSorted is already explicitly tracked under #104877 (PR #103786 sighting 2026-05-14). Pure indexOf is a new shape variant within the same family — currently 9 hits across 9 distinct PRs in 7 days (PRs #103545, #103786, #104431, #104493, #104705, #104751, #105041, #100377, #96886). I'll add indexOf to the family-shape tracker.

Fixes in flight covering the family:

  • #104858 (@Algunenano, OPEN) — stringVectorConstantFixedString in FunctionsComparison.h + castForIf (covers isDistinctFrom/isNotDistinctFrom/if/nullIf String↔FixedString shapes)
  • #104804 (groeneai, OPEN) — cache->default_column wrapper-layer castColumn fix (covers caseWithExpression/transform)

indexOf and indexOfAssumeSorted are array-functions, not the binary-comparison family — likely a sibling fix needed for the array-position lookup path (binary search dispatch and/or the constant-vs-materialize argument promotion). I'll investigate the indexOf/indexOfAssumeSorted array-function path in a separate PR. Tracking under the #104877 umbrella.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=3c70a3490a9353117f2ecc65ca1458dd7285378c&name_0=PR

@clickhouse-gh

clickhouse-gh Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.70% +0.00%

Changed lines: Changed C/C++ lines covered: 517/569 (90.86%) · Uncovered code

Full report · Diff report

…on cache

The previous commit extended the `isFileCacheVersionSettled` fail-close rule from the
query condition cache to the format metadata cache, on both the per-source path
(`object_with_metadata`) and the split-decision path in `ReadFromFile`. That was too
broad and regressed master's `04207_parquet_metadata_cache_local_file`, which is
reproducibly red on every sequential stateless shard: a file is written and queried
right away, so it is never settled, the cache is bypassed, and the expected
hit/miss pattern collapses to all zeroes.

The two caches draw different conclusions from the same token. The query condition
cache skips whole row groups without reading them, so a token that cannot yet prove
a rewrite must fail close - that gate stays. The format metadata cache only reuses a
parsed footer, and reusing it across an unsettled token is the behaviour master
already has and pins with `04207`; a freshly written file is the common case, and
bypassing the cache for it reparses the footer on every query. The split decision
taken from such a footer is also not silently wrong: the read re-stats the file and
throws `FILE_CHANGED_WHILE_READING` when the token moved under it.

Also tag `04637_parquet_file_engine_bucketed_query_condition_cache` `no-parallel`.
The query condition cache is server-wide and size-bounded, so a concurrent test can
evict the entry between the two plain reads and turn the expected hit into a miss -
observed once on the `amd_asan_ubsan, distributed plan, parallel` shard, passing on
all three reruns. This matches the `no-parallel` tag `04207` carries for the same
reason.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Pushed 8f13aa22b81, which fixes the only real red on 47ff07799bc.

04207_parquet_metadata_cache_local_file — reproducible regression, fixed. Red on 9 sequential stateless shards, and reproducible on rerun (65/65, 284/284, with and without randomized settings). Cause was the previous commit 131ebb82db5, which extended the isFileCacheVersionSettled fail-close rule from the query condition cache to the format metadata cache. 04207 is a master test that writes a Parquet file and queries it immediately, so the token is never settled, the cache was bypassed, and the hit/miss pattern collapsed to 0 0 three times. The gate is now back to covering only the query condition cache — full reasoning in the thread.

04637_parquet_file_engine_bucketed_query_condition_cache — flake, mitigated. One failure on the amd_asan_ubsan, distributed plan, parallel shard (plain_second a miss instead of a hit), all 3 reruns passed. The query condition cache is server-wide and size-bounded, so a concurrent test can evict the entry between the two plain reads. Tagged no-parallel, the same tag and the same reason 04207 already carries.

Verified locally against a server built from this head: 04207, 04637, 04658, 04230, 04238, 04665 all pass; ninja clickhouse clean, no warnings.

Not merged master this pass: the branch is ~1.5 days behind and both reds were caused by this PR, so a merge would have cleared nothing.

CH Inc sync is still Failed. Needs manual intervention (job 91390417250) — private side, needs a human.

@clickhouse-gh

clickhouse-gh Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 0b62bd574 with master 7624c959a (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 690.42 MiB 687.44 MiB -2.98 MiB (-0.43%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Object file sizes

17 object files changed (+177.08 KiB total), 0 added.

Object file Master PR Δ
src/CMakeFiles/dbms.dir/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp.o 428.68 KiB 531.62 KiB +102.95 KiB (+24.01%)
src/CMakeFiles/dbms.dir/Storages/StorageFile.cpp.o 1008.61 KiB 1.04 MiB +57.05 KiB (+5.66%)

737 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.

Compile time of recompiled translation units

572 translation units recompiled, 4014 s compile time in total, 556 of them have a recent master baseline.

Translation unit Master PR Δ vs median
src/Storages/StorageFile.cpp 10.7 s 15.7 s +5.3 s (+52%)

Job report

…t-104251-parquet-single-file-parallelism

# Conflicts:
#	src/Storages/ObjectStorage/StorageObjectStorageSource.cpp
…t read

The fail-close check for a parallel single-file split compared
`expected_file_cache_version` only against a pre-open `stat`, and
`createReadBuffer` then reopened the path, so a concurrent truncating
rewrite landing between the `stat` and the `open` could pass the check
on the old token and hand the source the new file, applying a stale
row-group assignment. The post-open re-stat only flipped
`current_file_version_settled`, which merely bypasses the query
condition cache, and the stale token was still used as the
`ParquetMetadataCache` key via `object_with_metadata`.

The opened fd is not reachable through the read buffer (it may be an
mmap, io_uring, or compression wrapper), so bracket the open with a
second `stat` of the path instead: once the token has settled, any
write in the gap changes the token, so an unchanged token proves the
opened bytes match it. On a mismatch, a bucketed source now throws
`FILE_CHANGED_WHILE_READING` instead of proceeding, and a plain read
drops the token so neither the format metadata cache nor the query
condition cache keys the read under a version it may not describe.

The split decision gets the same bracket: if the token moved while the
footer was being read, fall back to a plain unsplit read instead of
handing out a bucket assignment that would deterministically fail.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Status update: resolved the conflict with master and implemented the remaining AI-review blocker; pushed 42d69c8a9bc.

Conflict resolution (82792269076): the only conflict was in src/Storages/ObjectStorage/StorageObjectStorageSource.cpp, where master added a !hasAttachedDeletes(...) guard on the same count-cache condition this PR guards with !file_bucket_info — kept both. Post-merge invariants re-verified: cluster-function protocol WITH_PARQUET_FILE_ROW_GROUP_COUNT = 9 unique and max, FILE_CHANGED_WHILE_READING = 1011 with END = 1011, both settings-history entries in the 26.8 bucket (matches VERSION_MINOR 8), query-condition-cache !file_bucket_info gates intact in both storages.

AI-review blocker fixed (42d69c8a9bc, replied in the thread): the fail-close for a parallel single-file split validated expected_file_cache_version only against a pre-open stat, so a rewrite in the stat/open gap could hand a bucketed source the new file with a stale row-group assignment, and the stale token was still used as the ParquetMetadataCache key. The opened fd is not reachable through the read buffer (mmap/io_uring/compression wrappers), so the open is now bracketed with a second stat: a settled, unchanged token proves the opened bytes match it. On mismatch, a bucketed source throws FILE_CHANGED_WHILE_READING, and a plain read drops the token so neither cache pairs the read with a version it may not describe. The split decision gets the same bracket and downgrades to an unsplit read if the token moved during the footer read.

Local verification: incremental ninja clickhouse clean (zero warnings); all seven affected stateless tests pass against a local server: 04207_parquet_metadata_cache_local_file (the master-pinned metadata-cache behavior is unaffected), 04230, 04238_parquet_single_file_parallelize_output_off, 04546_parquet_single_file_split_filter_columns, 04637_parquet_file_engine_bucketed_query_condition_cache, 04658_..._without_metadata_cache, 04665_parquet_single_file_split_nested_subcolumns.

CI on the previous head 8f13aa22b81: the only in-repo red was Performance Comparison (arm_release, master_head, 1/6) — the machine-readable report shows 0 slower / 0 unstable queries on client_time (the red is "10 errors, 1 too long" harness noise); CI is re-running on the new head anyway. CH Inc sync still needs manual intervention on the private side.

Remaining blockers are human calls: CI on 42d69c8a9bc, the AI re-verdict, approval, and the merge itself.

…t-104251-parquet-single-file-parallelism

# Conflicts:
#	docs/en/interfaces/specs/NativeProtocol.md
#	src/Processors/Formats/Impl/Parquet/Reader.cpp
…ened bytes

Address the AI review finding on the local `ParquetMetadataCache` key being
built from an unsettled file-version token: an in-place rewrite that keeps the
inode and the byte size and lands in the same filesystem timestamp tick reuses
the token, so a cached footer can describe a previous generation of the file
and `checkFileMatchesBucketAssignment` would validate the bucket against the
very footer the assignment was computed from.

Instead of gating the metadata cache on the settle window (which regressed the
master-pinned plain-path behavior of 04207_parquet_metadata_cache_local_file
when tried before, and would also disable single-file parallelism for freshly
written files), tie the assignment to the file generation actually opened:

- `ParquetFileBucketInfo` carries a new local-only `footer_digest` (SipHash of
  the re-serialized thrift footer the split was computed from). It is never
  serialized over the cluster protocol and does not raise the minimum protocol
  version.
- A per-bucket `StorageFileSource` no longer builds `object_with_metadata`, so
  it parses the footer of the bytes it actually opened instead of reusing a
  cached one; `checkFileMatchesBucketAssignment` now also compares the digest
  and throws `FILE_CHANGED_WHILE_READING` on a mismatch, failing close instead
  of silently applying a previous generation's row-group layout.
- The split decision keeps warming the cache for later queries; a stale cached
  footer at decision time now deterministically fails close at read time.

The plain (non-bucketed) read path is unchanged and keeps the master-pinned
metadata-cache behavior.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Status update (automated pass):

  • The PR had turned CONFLICTING against master again. Merged origin/master (commit 96b5df5). Two conflicts: src/Processors/Formats/Impl/Parquet/Reader.cpp (both sides added independent blocks at the same insertion point — kept both: this PR's bucket-invariant out-of-range check and master's new spatial-filter Phase B block) and the legacy docs/en/interfaces/specs/NativeProtocol.md (deleted on master together with the whole legacy Docusaurus tree; this PR's protocol-v9 documentation already lives in docs/reference/interfaces/specs/NativeProtocol.mdx, which auto-merged cleanly, so the legacy file was dropped). Invariants re-verified after the merge: cluster protocol version 9 unique and max, FILE_CHANGED_WHILE_READING = 1011 with END = 1011, query-condition-cache !file_bucket_info gates in both storages, both settings-history entries in the 26.8 bucket.
  • Addressed the AI review blocker on the unsettled local metadata-cache key (commit f8556b6): per-bucket sources now parse the footer of the bytes they actually opened and verify it against a new local-only footer_digest carried by the bucket assignment, failing close with FILE_CHANGED_WHILE_READING instead of validating the bucket against the very cached footer it was computed from. No settle-window gating, so the plain path (master-pinned 04207 behavior) and fresh-file parallelism are unchanged. Details in the reply on the original finding thread.
  • Local verification: incremental build clean; ParquetFileBucketInfo* gtests 10/10; stateless 04207, 04230, 04238, 04546_parquet_single_file_split_filter_columns, 04637, 04658, 04665 all pass.
  • CI on the previous head 42d69c8a9bc: the only in-repo red was Performance Comparison (arm, shard 1/6) flagging lazyMaterialization #15 as unstable — it is unstable on the ::old side too, i.e. master-side noise, no action. CH Inc sync still needs manual intervention on the private side.

CI is now running on the new head. No self-merge — leaving the merge decision to a human.

Comment thread src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp
The per-bucket profile-event accounting added for the single-file split treated
any row-group assignment as a partition of the file among several readers, so it
reported `ParquetPrunedRowGroups` relative to the assignment's own size. But the
query condition cache also builds an assignment - for a single reader of the
whole file, restricted to the row groups a previous run found matching - and
there the omitted row groups were pruned by the cache, not handed to another
reader. Accounting relative to the assignment made the event 0 for such a read,
so a warm-cache repeat of a query that prunes row groups stopped reporting any
pruning at all: `04512_parquet_geo_pruning_iceberg_renamed_column`,
`04513_parquet_geo_pruning_iceberg_renamed_bbox_column` and
`04514_parquet_geo_pruning_geostats_fallback` all lost their
`ParquetPrunedRowGroups` line.

A cache-derived assignment is now marked with
`FileBucketInfo::omitted_row_groups_are_pruned`, and `Parquet::ReadManager::init`
accounts for the whole file in that case (as before this feature) and for the
bucket alone for a real split.
`computeParquetFooterDigest` hashed the thrift re-serialization of
`FileMetaData`, and the generated `write` loads every enum field as an
enumerator. Thrift metadata can legitimately carry an out-of-range value there -
`encoding_stats` is advisory input a malformed or future writer may fill with
garbage, which is exactly why `Reader::columnChunkCanUseDictionaryFilter` reads
those fields through `isValidThriftEnum` - and loading it as an enumerator is
undefined behavior. Under `-fsanitize=enum` with `-fno-sanitize-recover=all` the
process aborts, so a file that reads perfectly well with a full scan turned into
a silently killed query on the ASan + UBSan build:
`04546_parquet_v3_dictionary_filter_bad_encoding_stats` produced no output at all
for every one of its queries.

The digest is now computed directly over the footer's layout - the schema shape
and every row group's and column chunk's row counts, byte sizes and offsets, with
a presence flag for each optional field. That is what a bucket assignment is
derived from, so it still distinguishes file generations, it is still identical
for a freshly parsed footer and the same footer served from
`ParquetMetadataCache`, and it reads no enum.
`computeBucketsByCount` applied its minimum-row-groups-per-bucket floor
unconditionally, so a file with fewer row groups than the floor stayed
single-source no matter what `input_format_parquet_min_bytes_to_split` and
`input_format_parquet_bytes_per_split_bucket` were set to. That contradicts the
compatibility contract recorded in `SettingsChangesHistory.cpp`, which promises
that `0` for both reproduces the pre-26.8 fan-out driven by the row-group count
alone, and left the new settings unable to opt out of the heuristic for a
large-row-group file.

The floor is part of the same size heuristic as the byte gates, so it is now
skipped exactly when both of them are disabled. With the defaults - and with any
non-zero threshold - nothing changes, so the `clickbench_parquet_short`
regression the floor was introduced for stays fixed.

Adds `04812_parquet_single_file_split_row_group_floor_compat`, which pins both
directions on an 8-row-group file.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Status update (automated pass): three fixes pushed, e618aa3a2a0.

1. 04512 / 04513 / 04514 (parquet_geo_pruning_*) — real regression from this PR, fixed in a3a2ccfda05. These master tests failed reproducibly on 9 shards. The per-bucket profile-event accounting introduced here treated any row-group assignment as a partition of the file among several readers, so it reported ParquetPrunedRowGroups relative to the assignment's own size. But the query condition cache also builds an assignment — for a single reader of the whole file, restricted to the row groups a previous run found matching — and there the omitted row groups were pruned by the cache, not handed to another reader, so the event became 0 and the tests lost their ParquetPrunedRowGroups: 1 line on the warm-cache query. A cache-derived assignment is now marked with FileBucketInfo::omitted_row_groups_are_pruned, and Parquet::ReadManager::init accounts for the whole file in that case (the pre-feature behavior) and for the bucket alone for a real split.

2. 04546_parquet_v3_dictionary_filter_bad_encoding_stats — real regression from this PR, fixed in 0c538d6ba47. It failed only on amd_asan_ubsan, distributed plan, parallel, with every clickhouse-local invocation producing no output at all. Cause: computeParquetFooterDigest (added in the previous pass) hashed the thrift re-serialization of FileMetaData, and the generated write loads every enum field as an enumerator. That test deliberately crafts a file with an out-of-range encoding_stats.page_type / encoding — advisory metadata Reader::columnChunkCanUseDictionaryFilter reads through isValidThriftEnum for exactly this reason — so the digest hit -fsanitize=enum, and with -fno-sanitize-recover=all the process aborted silently (the report goes to log_path, not stderr). The digest is now computed directly over the footer's layout (schema shape plus every row group's and column chunk's row counts, byte sizes and offsets, with a presence flag per optional field): it still distinguishes file generations, is still identical for a freshly parsed footer and the same footer served from ParquetMetadataCache, and reads no enum. Pinned by a new gtest that digests a footer carrying an out-of-range enumerator written via memcpy.

3. AI review blocker (the hard-coded 16-row-group floor) — fixed in e618aa3a2a0, details in the reply on the finding's thread. The floor is part of the same size heuristic as the two byte settings, so it is now skipped exactly when both of them are 0 — the values compatibility set to a pre-26.8 version restores — which makes the contract in SettingsChangesHistory.cpp hold. New regression test 04812_parquet_single_file_split_row_group_floor_compat on an 8-row-group file.

Local verification: ninja clickhouse unit_tests_dbms clean, zero warnings; ParquetFileBucketInfo* gtests 12/12 (2 new); stateless 04512, 04513, 04514, 04546_parquet_v3_dictionary_filter_bad_encoding_stats, 04207, 04230, 04238, 04546_parquet_single_file_split_filter_columns, 04637, 04658, 04665 and the new 04812 all pass against a local server.

No self-merge — leaving the merge decision to a human. CI is running on the new head.

…_cache` as no-parallel

The query condition cache is server-wide and size-bounded, so a concurrent
test can evict the entry between the two plain reads and turn the expected
hit into a miss - the same reason `04637_parquet_file_engine_bucketed_query_condition_cache`
carries the tag. Seen flaky once on `Stateless tests (arm_binary, parallel)`
(10/10 reruns passed):
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104431&sha=e618aa3a2a0a0d1793bd29ef23effa1042ae7c2c&name_0=PR&name_1=Stateless%20tests%20%28arm_binary%2C%20parallel%29

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Status update on head e618aa3a2a0 → pushed 70baebd4a72:

  • AI review verdict on e618aa3a2a0 flipped to ✅ "No remaining findings" — the three pass-fixes (ParquetPrunedRowGroups accounting, footer digest without thrift enum re-serialization, row-group-floor opt-out for the compat contract) cleared the last Major. 0 unresolved review threads.
  • CI on e618aa3a2a0 had 3 reds:
    • 04658_parquet_file_engine_query_condition_cache_without_metadata_cache flaked once on arm_binary, parallel (expected query-condition-cache hit came back a miss; 10/10 reruns passed). The query condition cache is server-wide and size-bounded, so a concurrent test can evict the entry between the two reads — the same reason 04637 carries no-parallel. Fixed by tagging the test no-parallel (70baebd4a72).
    • Integration tests (amd_msan, 4/8)test_tcp_handler_connection_limits = known unrelated issue Flaky test test_tcp_handler_connection_limits/test.py::test_query_count_limit: global 'Done processing connection' count races with unrelated connections #113555.
    • Performance Comparison (arm_release, 1/6) "10 errors, 1 too long" = harness noise; the machine-readable per-query report shows no changed queries on that shard and "no significant performance changes" overall (2797 queries, 1 slower/11 unstable within thresholds).
  • CH Inc sync still needs manual intervention on the private side (job 92470241209).

Remaining: fresh CI on 70baebd4a72, approval, and merge (left to a human).

Comment thread src/Storages/StorageFile.cpp
…eck fails

When the post-open version-token check detects a concurrent rewrite on a
plain (non-bucketed) read, the fallback dropped only the cache token, but
`current_file_size` and `current_file_last_modified` still came from the
pre-open `stat` and were later materialized by
`addRequestedFileLikeStorageVirtualsToChunk` as the `_size` / `_time`
virtual columns — so the query could read one file generation while
exposing virtual columns from another. Reset both optionals in the same
branch; the virtual columns are then filled with NULL.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Status update (automated pass, 2026-08-07)

Pushed b57d30f, addressing the sole AI Review Major (thread on src/Storages/StorageFile.cpp:1837): when the post-open version-token check detects a concurrent rewrite on a plain read, the fallback now also resets current_file_size and current_file_last_modified, so the _size / _time virtual columns are filled with NULL instead of exposing values from the pre-open stat of a possibly different file generation. Verified locally: incremental build clean, all five of this PR's locally-runnable stateless tests pass, and the normal (non-race) path still populates _size / _time.

CI triage on the previous head 70baebd4a72:

  1. Stress test (arm_debug)Test script failed, exit code 1: unrelated harness flake. fatal.log is empty (no server issue); the harness's own install_thread_pool_fault_injection verification query (SELECT value FROM system.server_settings WHERE name = 'cannot_allocate_thread_fault_injection_probability') timed out at receive_timeout=15 on the debug build under load and stress.py raised CalledProcessError (exit status 159 = TIMEOUT_EXCEEDED). The only tests executed were 00001_select_1 and 00234_disjunctive_equality_chains_optimization — nothing near Parquet or StorageFile. A fix for this exact failure mode is already open: Do not run the server-side AST fuzzer on the stress harness's own queries #113248
  2. Performance Comparison (arm_release, master_head, 3/6): the machine-readable all-query-metrics.tsv for the shard shows 0 faster / 0 slower / 0 unstable across 498 queries — the red is harness noise, no query-level changes.
  3. CH Inc sync — needs manual intervention on the private side (job 92703016411).

No self-merge; waiting on CI for b57d30f, the AI re-verdict, and approval.

@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Status update (automated pass):

  • The AI review verdict on b57d30f5b50 is ✅ Approve, and there are no unresolved review threads.
  • CI on b57d30f5b50 had 3 reds, all unrelated to this PR:
    1. 04561_ie_join_storage_join on the amd_llvm_coverage, ParallelReplicas shard — a master-side test issue fixed by Fix test under parallel replicas #113840 (ec1a1cdadf2, merged 2026-08-07), which was not yet in this branch.
    2. Stress test (amd_tsan): logical error Digest does not match in DatabaseReplicated::assertDigest on the DROP TABLE path — this PR does not touch replicated databases; the same failure class appears on unrelated PRs (Support IEJoin for joins ON two inequality comparisons #109920, Add file IO and integrate new keeper storage #112378), with fixes in flight (Stop stateless tests from leaving the shared database Replicated #113794, Fix the Replicated database digest diagnostic in sanitizer builds #113009).
    3. Performance comparison (arm, 3/6): the shard is labeled disk-almost-full with 10 harness errors; across all 6 shards the machine-readable metrics show 2 faster / 1 slower / 7 unstable over 2813 queries — no significant changes. The one "slower" (order_by_read_in_order) is a MergeTree read-path query this PR does not touch.
  • Merged origin/master into the branch (clean, zero conflicts) to pull in the Fix test under parallel replicas #113840 fix and clear red 1: e9ef40e3f51.
  • Re-verified invariants after the merge: cluster protocol version 9 unique and maximal, FILE_CHANGED_WHILE_READING = 1011 with END = 1011 atop master's 1010, settings-history entries in the 26.8 bucket, query-condition-cache and count-cache gates on file_bucket_info intact in both StorageFile and StorageObjectStorageSource.
  • Incremental build clean (zero warnings); all 5 locally-runnable Parquet split tests (04230, 04238, 04546_parquet_single_file_split_filter_columns, 04665, 04812) pass on the merged head.

Waiting for CI on e9ef40e3f51.

…ves in the split-size estimator

The reader renames the Parquet map tuple elements to the `keys` / `values`
subcolumn names `DataTypeMap` requires (`SchemaConverter` does this at the
output-tuple level), so a direct map-subcolumn read requests `m.keys` /
`m.values` — but `collectLogicalPaths` normalized the footer leaves to
`m.key` / `m.value`, so such a read matched no chunk, `projectedCompressedBytes`
collapsed to 0, and the byte-based gate suppressed the single-file split even
for multi-megabyte reads. Now the map branch contributes the fixed `keys` /
`values` name components (nested cases included via the recursion). Adds
`04821_parquet_single_file_split_map_subcolumns` covering a light `m.keys`
control, heavy `m.values`, and a map nested inside a tuple (`t.m.values`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Addressed the sole AI Review Major (thread on src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp:561-567) in 0b62bd574380: the split-size estimator's collectLogicalPaths now normalizes Parquet map leaves to the keys / values subcolumn names the reader exposes (nested cases included), so a heavy direct m.values read is charged its real bytes and the single-file split is no longer suppressed. New regression test 04821_parquet_single_file_split_map_subcolumns covers a light m.keys control, heavy m.values, and a map nested inside a tuple (t.m.values).

While writing the test I found that direct Map subcolumn reads from Parquet silently return default values (the format treats m.keys / m.values as missing columns) — a pre-existing defect on master and 26.7, both readers, independent of this PR; filed as #113976. Details in the thread reply.

Verified locally: incremental build clean; the new test plus 04230 / 04238 / 04546_split_filter_columns / 04665 / 04812 all pass.

CI triage on the previous head e9ef40e3f51:

  1. Performance Comparison (arm_release, master_head, 3/6) — red via unstable on insert_sequential_and_background_merges #0 and number_formatting_formats #2/#6/#10 (unstable on ::old as well = master-side noise) plus a disk-almost-full job error — same harness-noise class as the previous passes, no query regressions.
  2. CH Inc sync — "needs manual intervention" on the private side (job 93064153329); the new push triggers a fresh sync run anyway.

No self-merge; waiting on CI for 0b62bd574380, the AI re-verdict, and approval.

/// whole-map requests keep matching.
++idx;
String raw_rep = raw + "." + rep.name;
String logical_keys = logical.empty() ? "keys" : logical + ".keys";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

collectLogicalPaths is now hard-wired to the default Map spelling (keys / values), but the reader still has another supported naming mode here: when a Parquet map is requested as Array(Tuple(...)), SchemaConverter switches to MapTupleAsPlainTuple and exposes the same leaves as m.key / m.value instead of m.keys / m.values (src/Processors/Formats/Impl/Parquet/SchemaConverter.cpp:507-525).

In that mode requested_columns contains m.value, while this branch only emits m.values, so projectedCompressedBytes misses every value chunk and the size gate keeps a heavy m.value read single-source. Please preserve both spellings here (or drive the normalization from the same MapTupleAsPlainTuple decision as the reader) and add a regression test with an explicit Array(Tuple(key String, value String)) schema.

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

Labels

pr-not-for-changelog This PR should not be mentioned in the changelog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants