Skip to content

[Experiment] Antalya 26.6: Parquet v3 constant column skip - #2181

Open
UnamedRus wants to merge 51 commits into
antalya-26.6from
parquet-v3-constant-column-skip-antalya
Open

[Experiment] Antalya 26.6: Parquet v3 constant column skip#2181
UnamedRus wants to merge 51 commits into
antalya-26.6from
parquet-v3-constant-column-skip-antalya

Conversation

@UnamedRus

Copy link
Copy Markdown
Collaborator

Changelog category (leave one):

  • Performance Improvement

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

...

Documentation entry for user-facing changes

...

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful 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)
  • 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)

UnamedRus and others added 13 commits August 6, 2026 14:36
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…tch 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: c6b70b3
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: 34816a3
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: f260506
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: 2a20ab9
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: 114640e
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: a019cd7
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: a3ee936

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Commit c6b70b3 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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Commit c6b70b3 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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…ds in history

The setting was added by a019cd7 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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Workflow [PR], commit [eac9b83]

UnamedRus and others added 7 commits August 7, 2026 12:55
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 `<boost/algorithm/string.hpp>` and `<unordered_map>`/
  `<unordered_set>` includes (the upstream PR relied on transitive includes).

PR: #1337

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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: #2181

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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: #2181

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
@UnamedRus

Copy link
Copy Markdown
Collaborator Author

RowGroup level + push ColumnConst to transforms

┌───────────────────────────────────┬──────────┬─────────────┬───────────────┐
│              Metric               │ baseline │ ColumnConst │       Δ       │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ DefaultImplementationForNullsRows │ 3.367B   │ 0.718B      │ −78.7%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ RealTimeMicroseconds (wall)       │ 333.5M   │ 243.1M      │ −27.1%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ UserTimeMicroseconds              │ 21.17M   │ 18.22M      │ −13.9%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ OSCPUVirtualTimeMicroseconds      │ 23.93M   │ 21.04M      │ −12.1%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ S3GetObject                       │ 663      │ 467         │ −196 (−29.6%) │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ ParquetPrefetcherReadRandomRead   │ 662      │ 466         │ −196 (−29.6%) │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ ParquetFetchWaitTimeMicroseconds  │ 104.5M   │ 69.0M       │ −34%          │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ ReadBufferFromS3InitMicroseconds  │ 63.1M    │ 37.0M       │ −41%          │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ MemoryAllocatedWithoutCheckBytes  │ 486.8M   │ 359.3M      │ −26.2%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ SelectedBytes                     │ 2214.1M  │ 2115.7M     │ −4.4%         │
└───────────────────────────────────┴──────────┴─────────────┴───────────────┘

UnamedRus and others added 6 commits August 7, 2026 19:57
…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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
UnamedRus and others added 12 commits August 8, 2026 00:36
…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 <dtitmoav@gmail.com>
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 <dtitmoav@gmail.com>
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 <dtitmoav@gmail.com>
…e 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 <dtitmoav@gmail.com>
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 <dtitmoav@gmail.com>
…ent 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 <dtitmoav@gmail.com>
…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 `<DataTypes/DataTypesDecimal.h>`, where it is declared - add the
  include.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
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 <dtitmoav@gmail.com>
…eBase with Context management

Two object-storage read-path improvements that share the server-settings and startup wiring, so they land together.

Latency-aware host selection (opt-in, off by default):
- `HostResolver` keeps a per-address EWMA of observed request time-to-first-byte, fed back from the S3 client (`PocoHTTPClient`). `selectBest` biases the weighted-random address choice towards lower-latency front-ends, the connection pool hands out the fastest pooled connection at dispatch, and opens fresh connections to fast front-ends instead of reusing slow-backend ones. Server setting `http_latency_aware_host_selection` (applied in both server and `clickhouse-local`).

Object-storage identity cache productionization:
- Replace the hand-rolled clear-on-overflow map in `ObjectStorageIdentityCache` with `CacheBase` (SLRU, size-bounded, per-entry weight, `CurrentMetrics` for bytes/cells).
- Make it Context-managed: server settings `object_storage_identity_cache_size` / `_policy` / `_size_ratio`, reachable from the low-level object-storage layer via `Context::getGlobalContextInstance` (null-safe fallback to an uncached request), and `SYSTEM DROP OBJECT STORAGE IDENTITY CACHE`.

Verified end-to-end on same-region AWS S3: warm reads issue zero `GetObjectAttributes`, `SYSTEM DROP OBJECT STORAGE IDENTITY CACHE` resets the cache, and a size of 0 disables it. Latency-aware selection is neutral on a healthy same-region path (concurrency hides per-request latency) and is intended as opt-in insurance for high-latency / degraded-front-end conditions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…plification

Read coalescing bridges gaps shorter than the seek threshold to save requests, but many sub-threshold gaps accumulate into a task that is almost entirely filler: reading a tiny, scattered (RLE / near-constant) column drags in its neighbouring columns and amplifies a few KB of wanted data into hundreds of MB read.

Add `input_format_parquet_read_min_fill_ratio` (0 = off): during coalescing, `Prefetcher` stops extending a task once bridging the next gap would drop its wanted/span fraction below the ratio. Gaps below a small absolute floor are always bridged, so dense reads (fill ~1) are never split. This is the inner segmentation step of a top-down plan whose outer partition is the existing part-boundary guard (`gapCrossesBoundary`), so it composes with alignment and the read splitter without re-merging. New profile event `ParquetPrefetcherFillRatioLimitedTasks`.

Measured on a same-region AWS S3 Iceberg table (100M rows): a scan of one sparse column drops from ~209 MB to ~43 KB read and ~2.4x faster wall; a genuinely non-constant sparse column benefits equally (~211 MB to ~50 KB), which page-level constant skip could not do. Dense scans are unchanged (guard never fires). A mixed keys+metric scan is unchanged (the metric's dense tasks keep fill high), with no regression.

Also strips temporary footer debug logging and now-unused `logger_useful.h` includes, and lands the accompanying in-progress v3 read-path work on this branch (parallel segment reads / speculative footer plumbing, Iceberg footer-size hint via split offsets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
… IDENTITY CACHE

The new `SYSTEM DROP OBJECT STORAGE IDENTITY CACHE` privilege (added with the
Context-managed identity cache) adds a row to `SHOW PRIVILEGES`, so the stateless
test reference needs the corresponding line.

CI report: https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2181&sha=5b16210f9e7dbf582ab7e23983337a9244f2b9fe&name_0=PR&name_1=Fast%20test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Building the list of Iceberg data files to read was single-threaded: one
producer thread drained `SingleThreadIcebergKeysIterator`, which for every
manifest entry deserializes the per-column lower/upper bounds and evaluates
the min/max `KeyCondition` to prune. On a wide table (e.g. ~17.5k data files,
of which ~17.5k are pruned to a few dozen survivors) this serial pass is the
dominant startup stall: reader threads block in `IcebergIterator::next` on the
`blocking_queue` until the producer reaches the surviving entries, and the
`IcebergMetadataFilesCache` only caches the manifest file contents, not the
per-entry pruning, so the work is repeated every query.

The manifest-processing stack was already built for concurrency —
`ManifestFileIterator::next` uses an atomic row cursor, the Avro deserializer
guards its per-row cache with a `SharedMutex`, and the pruner/schema caches
are mutex- or `SharedMutex`-guarded — but nothing drove it in parallel.

Add `iceberg_metadata_processing_threads` (default 1 = the previous serial
producer). When greater than 1 (0 = auto = number of CPU cores), spawn that
many producer threads that share the manifest-file cursor through a small
mutex (only the cheap per-file advance — `getManifestFile` + build — is
serialized) and drain each manifest file's entries concurrently, so the heavy
per-entry bound-deserialize and min/max pruning is spread across threads. The
last worker to finish closes the queue; exceptions propagate through the
existing `exception`/`exception_mutex` path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
@UnamedRus

Copy link
Copy Markdown
Collaborator Author

S3 frontend->backend latency

TLDR:
Each time you make dns request against S3 domain, aws return 8 IPs, over span of 5min, you can gather around 600 IPs of S3 frontends.

Some of them are slower/some of them are faster.
Some stats:

Decile cohorts by per-IP TTFB p50 (575 frontends, ~57/decile)

┌──────────────┬─────┬─────────────────┬──────┬───────────┐
│    decile    │ IPs │ ttfb range (ms) │ mid  │ total mid │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│  1 (fastest) │  57 │ 33.1–35.0       │ 34.3 │      62.1 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            2 │  58 │ 35.0–36.4       │ 35.7 │      67.1 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            3 │  57 │ 36.4–37.8       │ 37.2 │      72.5 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            4 │  58 │ 37.8–39.2       │ 38.4 │      72.0 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            5 │  57 │ 39.2–42.9       │ 40.4 │      77.0 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            6 │  58 │ 43.1–47.0       │ 45.5 │      73.5 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            7 │  57 │ 47.0–48.7       │ 48.0 │      74.0 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            8 │  58 │ 48.7–49.9       │ 49.2 │      74.9 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│            9 │  57 │ 50.0–52.3       │ 50.9 │      76.2 │
├──────────────┼─────┼─────────────────┼──────┼───────────┤
│ 10 (slowest) │  58 │ 52.3–243.5      │ 53.8 │      81.9 │
└──────────────┴─────┴─────────────────┴──────┴───────────┘

Request-level TTFB histogram (11,500 reqs)

20-30    72   0.6%  ▍
30-40  5071  44.1%  ██████████████████████████████████████████████████
40-50  2896  25.2%  ████████████████████████████
50-60  2482  21.6%  ████████████████████████
60-70   534   4.6%  █████
70-80   197   1.7%  ██
80-90   110   1.0%  █
90-100   64   0.6%
100-150  51   0.4%
150+     23   0.2%  ▍   ← the broken frontend + rare spikes
91% of requests land in 30–60 ms; thin tail (0.6% ≥ 80 ms, 0.2% ≥ 150 ms).

For relatively huge GET requests (over dozens of MB), prefetches, (or requests at later stage of query, when there is already enough data in fly), it probably wouldn't have huge affect.
But, for initial requests, when CH go by sequential metadata pipeline:

  1. metadata.json
  2. manifest list
  3. manifest file
  4. parquet footer (x2)

It probably could add extra 60ms to query execution (ie sum of extra sequential latency, wait itself in total will be bigger probably) if we unlucky enough.

Basically, idea was to store TTFB alongside statistics of certain connection, sort and return next free connection by that/open new connection to fast frontend instead of reusing one for slower.

@UnamedRus

Copy link
Copy Markdown
Collaborator Author

Straddle across MultiPart boundaries

When object is created using multipart upload, AWS S3 and some other backends doesn't "glue" parts together, they are likely are not being moved from where they were written.
So, it does mean when getRange request is requesting byte range which traverse across boundary between 2 parts, there is penalty (constant, around 60-80ms) to glue them together.

┌───────┬────────────┬──────────────┬──────┬───────────┬───────┬─────────┬─────────┐
│ size  │ med within │ med straddle │ Δmed │  95% CI   │ ratio │ Cliff δ │ p (MWU) │
├───────┼────────────┼──────────────┼──────┼───────────┼───────┼─────────┼─────────┤
│ 256 K │      134.0 │        237.2 │ +103 │ [77, 114] │ 1.77× │   −0.65 │      ~0 │
├───────┼────────────┼──────────────┼──────┼───────────┼───────┼─────────┼─────────┤
│  1 MB │      152.1 │        237.9 │  +86 │ [72, 102] │ 1.56× │   −0.72 │      ~0 │
├───────┼────────────┼──────────────┼──────┼───────────┼───────┼─────────┼─────────┤
│  4 MB │      192.0 │        277.6 │  +86 │ [67, 110] │ 1.45× │   −0.54 │   9e-16 │
├───────┼────────────┼──────────────┼──────┼───────────┼───────┼─────────┼─────────┤
│  8 MB │      207.8 │        322.3 │ +115 │ [88, 139] │ 1.55× │   −0.52 │   1e-14 │
└───────┴────────────┴──────────────┴──────┴───────────┴───────┴─────────┴─────────┘

Decomposition — TTFB flat, penalty 100% transfer

┌───────┬───────────────────┬───────────────────┬────────┐
│ size  │ ttfb within→strad │ xfer within→strad │ xfer Δ │
├───────┼───────────────────┼───────────────────┼────────┤
│ 256 K │     122.3 → 125.8 │       7.6 → 102.0 │  +94.5 │
├───────┼───────────────────┼───────────────────┼────────┤
│  1 MB │     131.3 → 126.3 │      14.1 → 117.8 │ +103.7 │
├───────┼───────────────────┼───────────────────┼────────┤
│  4 MB │     129.0 → 130.0 │      39.6 → 146.5 │ +106.9 │
├───────┼───────────────────┼───────────────────┼────────┤
│  8 MB │     127.1 → 121.2 │      60.7 → 203.5 │ +142.7 │
└───────┴───────────────────┴───────────────────┴────────┘

xfer = transfer time = total − TTFB.

There are multiple sources, which decide byte range associated with certain read request:

  1. Read, ReadAt. (when something request exact byte range)
  2. Prefetch (optimistically try to load in advance)
  3. *_min_bytes_for_seek (If gap between 2 reads is smaller than 4MB they can be glued together)

Probably, seek and prefetch could be "optimized" not to glue or split requests at boundaries between parts.
Problem here, that we do not know exactly in advance what are those boundaries, there is special S3 request GetObjectAttributes, but we may not have access to it enabled, or it only show parts sizes if extra checksum was enabled during multipart upload.
Somewhat lucky for us, ClickHouse and spark are using ~similar algorithm which define sizes of multipart uploads, it gradually increase from 16MB -> 32MB -> 64 -> ..., so theoretically we can just think that file is being split every 16MB, and it might work as good approximation.

@UnamedRus

UnamedRus commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Object identity cache

ClickHouse have multiple caches for Parquet/Iceberg/Data, but some of them are cached on: object_path, etag

This does make sense for certain use cases with s3, (not to use expired cache if object is changed)
But it lead to creation of 2 head requests per object before hitting cache:

  1. CH know object path, but don't know etag -> HEAD request
  2. CH know object path + etag -> cache.get (MISS, because 1. didn't put result in cache) -> HEAD request

But, this protection (against expired object) doesn't make sense for iceberg tables model, because in their consistency model, they do not store etag value in manifest lists. So, even if object is replaced we will not know/detect that.

So, idea was to introduce very small cache just for those engines, which does not fix in contract value of etag.

 path → {exists, size, etag/version, mtime} 

And use it, in order to probe those caches, which use object_path + etag as key.

@UnamedRus

Copy link
Copy Markdown
Collaborator Author

Parquet footer

When reading Parquet file:

  1. ClickHouse first was reading last 64KB of object (GET 1)
  2. Extract footer len from it
  3. Read rest of footer (GET 2)

So, total latency was (GET 1) + (GET 2)
What we can do better.
There is little to no latency difference for relatively small requests.

Single GET — TTFB-bound, size barely matters

  ┌───────┬──────┬──────┬──────┐
  │ size  │ p50  │ p90  │ p99  │
  ├───────┼──────┼──────┼──────┤
  │  64 K │ 25.3 │ 31.5 │ 59.3 │
  ├───────┼──────┼──────┼──────┤
  │ 256 K │ 26.7 │ 33.0 │ 60.2 │
  ├───────┼──────┼──────┼──────┤
  │ 512 K │ 28.2 │ 42.2 │ 75.8 │
  ├───────┼──────┼──────┼──────┤
  │  1 MB │ 29.9 │ 39.9 │  178 │
  ├───────┼──────┼──────┼──────┤
  │  2 MB │ 32.9 │ 46.8 │  160 │
  └───────┴──────┴──────┴──────┘

So, we either can be bit more generous and just read more data for GET 1 request (in hope that whole footer will land in it)
Or/And we can try to estimate parquet footer size from column num, parquet file size, row count. (what is available for us from iceberg metadata).

Another option, in case of big estimates, do 2 requests in parallel:

  1. GET 1 is last 2 MB of file
  2. GET 2 is -2MB to rest of our estimate
┌────────┬─────┬────────────┬─────────────────┬──────────────┐
│ footer │ RTs │ oracle p50 │ spec_serial p50 │ spec_par p50 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│  256 K │   1 │       26.7 │            33.6 │         34.3 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   1 MB │   1 │       30.1 │            33.9 │         33.1 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   2 MB │   1 │       33.7 │            33.0 │         33.9 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   3 MB │   2 │       36.7 │            65.0 │         36.1 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   4 MB │   2 │       46.3 │            68.0 │         37.6 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   6 MB │   2 │       68.3 │            81.2 │         47.5 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   8 MB │   2 │       91.3 │           103.9 │         68.6 │
└────────┴─────┴────────────┴─────────────────┴──────────────┘

Below 2 MB (the common case) — 1 GET, essentially free

spec ≈ oracle ≈ ~33 ms. Over-reading to 2 MB when the footer is 256 K costs only +7 ms (TTFB-bound, transfer negligible). So a 2 MB estimate handles the typical footer in one round-trip.

Above 2 MB — the serial 2nd GET hurts, parallel fixes it

┌────────┬──────────────────┬──────────────────┬────────────────┐
│ footer │ serial vs oracle │  par vs oracle   │ parallel saves │
├────────┼──────────────────┼──────────────────┼────────────────┤
│   3 MB │ +28.3 ms (1.77×) │  −0.6 ms (0.98×) │        28.9 ms │
├────────┼──────────────────┼──────────────────┼────────────────┤
│   4 MB │ +21.7 ms (1.47×) │  −8.7 ms (0.81×) │        30.4 ms │
├────────┼──────────────────┼──────────────────┼────────────────┤
│   6 MB │ +12.9 ms (1.19×) │ −20.8 ms (0.70×) │        33.7 ms │
├────────┼──────────────────┼──────────────────┼────────────────┤
│   8 MB │ +12.6 ms (1.14×) │ −22.6 ms (0.75×) │        35.3 ms │
└────────┴──────────────────┴──────────────────┴────────────────┘

@UnamedRus

Copy link
Copy Markdown
Collaborator Author

Guard for *_min_bytes_for_seek

ClickHouse glue reads in one GET request, if there is less than 4MB gap between them
Basically, when we need to read nicely compressed columns, due to shape of Parquet files, it's possible that we would end up with:
(need) 64kb....4MB(of unneeded columns)...(need) 128kb....4MB(of unneeded columns)...(need) 64k...

Which in general, was tradeoff we were willing to accept, but as always there should be some limit to that.
Try to bridge blocks in very few GET requests, shouldn't lead to reading, which return 90%-95% of bridge and only 10% of what we actually need.

So, idea was to allow creation of more GET requests, for some pathological cases.

@UnamedRus

UnamedRus commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Hedged requests

Send duplicate GET request, if original is not answering (TTFB? total latency?) for longer than X
Alone, did show some usefulness(but may be with latency aware host selector will be less usable/trigger less frequently), but not tested well, and actually should use TTFB as metric.

Parallelize iceberg manifest calculation

Done in #1753

Serial: one producer thread Avro-decodes + min/max-evaluates all ~17.5k manifest entries before any reader starts. Parallel (=16): fan that over N threads → cut the startup stall. Measured via IcebergMetadataReadWaitTimeMicroseconds (summed across reader threads) and wall.

Measured — production binary qbench

Serial workload (max_threads=16, uncontended):

┌─────────────────────────┬───────────────────────┬──────────┐
│                         │ metawait (µs, summed) │ wall p50 │
├─────────────────────────┼───────────────────────┼──────────┤
│ baseline (threads=1)    │                11.3 M │  1973 ms │
├─────────────────────────┼───────────────────────┼──────────┤
│ icebergpar (threads=16) │                 3.7 M │  1846 ms │
├─────────────────────────┼───────────────────────┼──────────┤
│ Δ                       │    −67% (3× realized) │    −6.4% │
└─────────────────────────┴───────────────────────┴──────────┘

Small wall gain — when uncontended the metadata phase is a minor fraction of wall, and only ~3× of the 16 threads is realized (producer setup + shared-manifest serialization overhead).

Concurrent workload (queries overlapping):

┌─────────────────────────┬───────────────────────┬──────────┐
│                         │ metawait (µs, summed) │ wall p50 │
├─────────────────────────┼───────────────────────┼──────────┤
│ baseline (threads=1)    │              ~46–65 M │  4882 ms │
├─────────────────────────┼───────────────────────┼──────────┤
│ icebergpar (threads=16) │              ~2.5–8 M │  3065 ms │
├─────────────────────────┼───────────────────────┼──────────┤
│ Δ                       │ ~−90% (≈11× realized) │     −37% │
└─────────────────────────┴───────────────────────┴──────────┘

@UnamedRus

UnamedRus commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Constant Column optimization

Iceberg

Iceberg store metadata/statistics for some columns.
So, we can understand that some column in whole parquet file have the same value, so:

  1. We can avoid reading ranges associated with it.
  2. Produce it to next ClickHouse Transforms in pipeline as const column in chunk, which allow to process it more efficiently for some operations.

Done in #1069

Parquet

Parquet in footer themself store statistics for all columns in file for RG.
There are certain variations of it(depends on writers), but in general
(min_value == max_value AND (null_count == 0 OR (definition level = required)) or null_count == num_values
For non numerics is_min_value_exact/is_max_value_exact is true

is safe to use., then again we can avoid read this column and push it as const in chunk.

Another case,
If column is not constant in RG, but constant in some of Pages in chunk, and Parquet have Column Index
It store

  Per column chunk, arrays indexed by page:
  - min_values[], max_values[] — one min/max per data page (sort-order-aware, same semantics as min_value).
  - null_pages[] — bool per page: page is entirely null.
  - null_counts[] (optional) — per-page null count.

For non numerics, if min_values/max_values are truncated, max_value should be truncated-and-increment.
SO, if min_values[i] = max_values[i], we know for sure that there is only one value in page.

And again, we can do the same optimization (not read page/range of pages), create const column in chunk

@ianton-ru ianton-ru 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.

I count at least 5 different independent features in this PR

  1. Cache for S3 object metadata
  2. Host resolving with weights based on latency
  3. Hint for parquet footer size
  4. Parallel parquet file loading
  5. Constant columns
    Plus namespace filter from #1019

This is not PR for merging, only as a proof of concept.

auto fetch = [&](const S3::Client & c) -> S3::ObjectInfo
{
if (identity_only)
return S3::getObjectIdentity(c, uri.bucket, path, /*version_id=*/ {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does this make something faster?
getObjectInfo makes HeadObject request, getObjectIdentity makes GetObjectAttributes with fallback to getObjectInfo.
As I understand, GetObjectAttributes give profit if replaced several different requests, but in current case it replaces only one.
Fallback makes only worse for some old S3-compatible storages without GetObjectAttributes support.

@UnamedRus UnamedRus Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Does this make something faster?

No, it's not faster.
It was attempt to gather/cache real multiPart boundaries from S3 via GetObjectAttributes.
But, it need special conditions (multiPart upload with extra checksums) AND have access to GetObjectAttributes.

So, probably if it will be implemented using Head is more reliable.

if (auto global_context = Context::getGlobalContextInstance())
identity_cache = global_context->getObjectStorageIdentityCache();

if (identity_cache)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As I understand this caches also metadata during direct reading with S3 table function. Cache is good for immutable objects, "owned" by ClickHouse, but not for all objects on S3.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

As I understand this caches also metadata during direct reading with S3 table function.

It might, and if so, need to be fixed, but doesn't affect bench for now.

Cache is good for immutable objects, "owned" by ClickHouse, but not for all objects on S3.

I think, we should do it for "any" immutable kind of object. (either ClickHouse owned or Iceberg catalog).
We potentially could do it for any object of any source, if we use conditional GET later (GET object/range if etag match X, where X is cached value, so we can get cache invalidation & data request in one go, but it's unnecessary for Iceberg)

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does this number have grounding? From my point of view, guessing "set initial footer size based on random constant number of rows per group" is not worse and not better than "set initial footer size as 64 kB"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

is not worse and not better than "set initial footer size as 64 kB"

Requesting larger footer, up to 2MB is basically free.
And if we can save 1 GET request almost for free...

┌────────┬─────┬────────────┬─────────────────┬──────────────┐
│ footer │ RTs │ oracle p50 │ spec_serial p50 │ spec_par p50 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│  256 K │   1 │       26.7 │            33.6 │         34.3 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   1 MB │   1 │       30.1 │            33.9 │         33.1 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤
│   2 MB │   1 │       33.7 │            33.0 │         33.9 │
├────────┼─────┼────────────┼─────────────────┼──────────────┤

But, logic for footer estimation can be done better, i think.
We should have list of columns (with types?), file size, number of rows from iceberg metadata.
Spark for example split by default using 128MB of uncompressed data, we can probably estimate that from types x col_num from one side and using compressed parquet size X ("average/good" parquet compression) from another.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

So may be just increase default const footer size to 2Mb instead of complex logic?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, it's one option i keep in mind.

But, for very small files, it probably nice to somewhat scale it down.
Or, for big files, for >2MB estimated footer, we can (would like to) fire 2 requests in parallel, which allow to compress latency as well.

So, i think in the end we can decide on:

  1. complex estimator, which overestimate with good enough margin footer size.
  2. very simple estimator based on file size (just to reduce size of estimation for small one)
  3. just plain request 1-2 MB last bytes

@ianton-ru

Copy link
Copy Markdown

AI found a lot of issues for most features, but I think make sense split PR on pieces, one feature in one PR, and recheck these PRs.

@UnamedRus

UnamedRus commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

This is not PR for merging, only as a proof of concept.

Yes, and it was intendent as such.
I've need builds to test it on benchmark, and if we deem some ideas useful, we need to understand which one are more useful than others to find best value order of implementation.

but I think make sense split PR on pieces, one feature in one PR, and recheck these PRs.

I think, it bit early to do that. First we need to find (from bench on real data) which one we really (if any) need.

UnamedRus and others added 8 commits August 12, 2026 16:53
`insertRowToLogTable` took the row content as an eagerly-built `String`, so the
per-manifest-entry call sites evaluated `getContent(row_index)` (serializing the
whole manifest entry) for every data-file entry on every query — even with
`iceberg_metadata_log_level = None` (the default), where the result is
immediately discarded by the level check inside the function.

On wide Iceberg tables with many manifest entries this dominates query planning:
on a 17,492-file table the single-threaded manifest pruning spent ~25s (summed
across reader threads) building strings that were thrown away. The eager
evaluation was the regression that made `iceberg_metadata_processing_threads`
look load-bearing — it was mostly parallelizing wasted work.

Make the row lazy: `insertRowToLogTable` now takes `std::function<String()>`
and invokes it only after the log-level check passes. All six call sites pass a
closure instead of a pre-built string.

Measured on a synthetic wide table (62x1 GiB files, ~17.5k manifest entries,
q reads 8 of 437 columns), warm, single metadata thread:

  IcebergMetadataReadWaitTimeMicroseconds (summed): 24.8s -> 1.6s  (~15x)
  query wall:                                       4.09s -> 1.54s (~2.7x)

with identical pruning (17,492 files) and identical bytes read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
`estimateParquetFooterSize` used `per_column_chunk = 112` B, which overshot the
real thrift-compact ColumnMetaData size. For wide / few-stats-column Iceberg
tables the footer is dominated by the `num_columns * per_column_chunk *
num_row_groups` term, so the whole prediction ran ~2.2x large: on a 437-column,
8-row-group, ~1 GiB file the predictor asked for 519 KiB when the footer is
240 KiB. That is safe (an overshoot only reads a slightly larger tail) but
wasteful.

Measured the actual per-chunk cost at ~51 B/chunk on that file (large offsets
serialize to ~5 B varints); set `per_column_chunk = 64` (headroom for longer
column names) and reduce the safety margin 1.33x -> 1.25x. Validated the
predicted-vs-actual footer size across all 62 files of a synthetic wide table
(437 cols, 8 row groups): prediction drops from 519 KiB (2.16x) to ~288 KiB
(1.17x) with zero underestimates, so the footer is still captured in a single
speculative tail read with much less wasted read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
The existing `input_format_parquet_hedged_read_threshold_ms` hedges a remote
Parquet read once its *total* time exceeds a budget. On object storage that
conflates two very different situations: a connection that has stalled (no
bytes yet) and a large transfer that is streaming normally but simply takes a
while. Budgeting on total time either hedges healthy large reads (wasteful
duplicate GETs) or sets the budget so high it no longer cuts the stall tail.

Add `input_format_parquet_hedged_read_ttfb_threshold_ms`: fire the hedge when
the primary read has not received its *first byte* within the threshold.
Time-to-first-byte is largely independent of read size, so this isolates a
stalled/slow frontend from a slow-but-progressing transfer. When set (> 0) it
takes precedence over the total-time threshold; both remain bounded by
`input_format_parquet_hedged_read_max_bytes` and
`input_format_parquet_hedged_read_max_inflight`.

Mechanism: the primary read now passes a progress callback into `readBigAt`
that notifies a new per-task `first_byte` latch on the first reported bytes;
`hedgeReadSync` waits on `first_byte` (TTFB mode) instead of `completion`.
`first_byte` is also notified unconditionally when the primary finishes, so a
read that produced no progress callback (cached region, split path) never
leaves a hedge waiting.

Measured on a synthetic wide-Iceberg table (62x1 GiB files, q reads ~690 MiB
across part-sized S3 GETs, v3 + parallel Iceberg metadata):

  ttfb_ms  p50ms  maxms  readMiB  hedges  wins
  0(off)    6558   7499      690       0     0
  5         6149   6490     1075     158   158
  20        5987   6179     1043     133   133
  40        5341   5942      749      21    21

At 40 ms: p50 -19%, tail max -21%, +9% bytes (21 hedges, all winning) - the
trigger fires only on genuinely stalled frontends. Result is unchanged with
the hedge on (identical output hash across thresholds); the loser read is
discarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
This experiment branch (Parquet v3 constant column skip) is not for merge; it
exists to get a green build for testing the parquet-v3 read path. The
`04401_system_reset_ddl_worker_access` stateless test comes from the
antalya-26.6 base backport of ClickHouse#108460 and fails there (the SYSTEM RESET DDL
WORKER privilege isn't enforced - unprivileged access is not denied), unrelated
to the parquet changes on this branch. Dropping it here unblocks the build.

This is NOT a fix for the underlying access-control regression, which lives in
the antalya-26.6 base (commit 03e147a) and should be fixed or reverted
there; a future merge of that base will reintroduce this test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…Attributes behind a read setting

Reading object metadata on a lake scan (`StorageObjectStorageSource`) needs the object size and etag
(the etag keys the page / filesystem / Parquet-metadata caches). The identity fast path fetched these
via `GetObjectAttributes`, which also returns the multipart part layout used for part-aligned reads -
but that request is heavier than a plain `HEAD` and is issued per file, so a cold, cache-empty scan
paid one `GetObjectAttributes` per object (measured ~2x slower cold on a wide-Iceberg benchmark) for
part offsets it usually does not use.

Default `S3ObjectStorage::getObjectMetadata` to a plain `HEAD` (size + etag, no part offsets), and add
a read setting `object_storage_identity_cache_fetch_part_offsets` (default off) that opts back into
`GetObjectAttributes` when part-aligned reads want the layout. A new 3-argument
`IObjectStorage::getObjectMetadata` overload carries the flag; its default implementation ignores it,
so non-S3 storages and the many callers that do not need part offsets are unchanged. The etag is still
returned on both paths, so the object-storage caches keep working.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Resolve conflict in RestCatalog::dropTable: keep the branch's namespace-allowed
guard and adopt antalya-26.6's endpoint construction (config.prefix +
encodeNamespaceForURI + NAMESPACES_ENDPOINT).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
The parallel manifest-entry pruning hands entries from the producer threads to
the consumer through a `ConcurrentBoundedQueue`, one entry at a time. The
per-entry work is a tiny min/max prune, so on a wide, many-file table (tens of
thousands of manifest entries) the queue's mutex/condvar became the dominant
cost: a cold scan spent ~850 ms with the metadata threads ~99% blocked in
`__lll_lock_wait` / `__futex` under `IcebergIterator::next` -> `blocking_queue`,
doing almost no actual work (the CPU profiler saw ~0 samples there). More
threads only added contention, which is why cold was flat across thread counts
and no faster than the single-threaded 26.3 path.

Hand off entries in batches of `producer_batch_size` (256) instead of one at a
time: each producer accumulates into a local vector and pushes a full batch
(flushing the partial batch when its work ends), and the consumer serves entries
from a locally-held batch under a dedicated `consumer_mutex`, touching the shared
queue only once per batch to refill. This amortises the shared-queue lock ~256x,
moving the per-entry path onto cheap local buffers. Queue bound is now counted in
batches. No change to ordering guarantees (there were none) or results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants