Parquet: read-path redesign — serve bytes as they arrive, bounded coalescing, lifetime memory pools, read-ahead controller, filesystem-cache fixes - #2275
Open
UnamedRus wants to merge 27 commits into
Conversation
UnamedRus
added a commit
that referenced
this pull request
Aug 27, 2026
`finishRowSubgroupStage` handles a subgroup whose `rows_pass` became 0 after `applyPrewhere` by breaking out of the `ColumnData` case without advancing `read_ptr`, and relying on the "start next subgroup" loop below to revisit the current subgroup, deallocate it and move on. That loop's compare-exchange took its expected value from `stage.load()`, so it always succeeded. The read-path change replaced the expected value with `NotStarted` to keep read-ahead from admitting a subgroup twice; for the fully-filtered current subgroup the exchange now failed, nothing advanced, and `read` hit the deadlock detector: `Logical error: Deadlock in Parquet::ReadManager (thread pool)`. Read-ahead no longer admits subgroups early, so the original expectation is restored. Only one subgroup of a row group is in progress at a time, so the exchange cannot hit anything else. Failed in `03596_parquet_prewhere_page_skip_bug` (server abort): https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2275&sha=0df5ce6be0296b179730e6b422e9dddc9751abd4&name_0=PR PR: #2275 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com> (cherry picked from commit 062538f)
UnamedRus
added a commit
that referenced
this pull request
Aug 27, 2026
The row-group bound on read coalescing treated every byte of the file as belonging to some row group, so the page indexes and footer after the last row group could not join that row group's data read. For a small file this cost one extra request: `03723_parquet_prefetcher_read_big_at` expected 2 random reads and got 3. The rule is now stated in terms of row groups, not offsets: a task may cover ranges from at most one row group, and ranges outside every row group (page indexes, bloom filters, footer) may join whichever row group the task already covers. `Prefetcher::setRowGroupRanges` takes the [start, end) ranges instead of a flattened boundary list. Failed in `03723_parquet_prefetcher_read_big_at`: https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2275&sha=0df5ce6be0296b179730e6b422e9dddc9751abd4&name_0=PR PR: #2275 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com> (cherry picked from commit ca07675)
UnamedRus
added a commit
that referenced
this pull request
Aug 27, 2026
`finishRowSubgroupStage` handles a subgroup whose `rows_pass` became 0 after `applyPrewhere` by breaking out of the `ColumnData` case without advancing `read_ptr`, and relying on the "start next subgroup" loop below to revisit the current subgroup, deallocate it and move on. That loop's compare-exchange took its expected value from `stage.load()`, so it always succeeded. The read-path change replaced the expected value with `NotStarted` to keep read-ahead from admitting a subgroup twice; for the fully-filtered current subgroup the exchange now failed, nothing advanced, and `read` hit the deadlock detector: `Logical error: Deadlock in Parquet::ReadManager (thread pool)`. Read-ahead no longer admits subgroups early, so the original expectation is restored. Only one subgroup of a row group is in progress at a time, so the exchange cannot hit anything else. Failed in `03596_parquet_prewhere_page_skip_bug` (server abort): https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2275&sha=0df5ce6be0296b179730e6b422e9dddc9751abd4&name_0=PR PR: #2275 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
UnamedRus
added a commit
that referenced
this pull request
Aug 27, 2026
The row-group bound on read coalescing treated every byte of the file as belonging to some row group, so the page indexes and footer after the last row group could not join that row group's data read. For a small file this cost one extra request: `03723_parquet_prefetcher_read_big_at` expected 2 random reads and got 3. The rule is now stated in terms of row groups, not offsets: a task may cover ranges from at most one row group, and ranges outside every row group (page indexes, bloom filters, footer) may join whichever row group the task already covers. `Prefetcher::setRowGroupRanges` takes the [start, end) ranges instead of a flattened boundary list. Failed in `03723_parquet_prefetcher_read_big_at`: https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2275&sha=0df5ce6be0296b179730e6b422e9dddc9751abd4&name_0=PR PR: #2275 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Spec and task-by-task plan for replacing the read-path workarounds of #2275 with three shippable phases: 1. `Prefetcher` serves a coalesced read's bytes as they arrive (`Task::bytes_ready` fed by the `readBigAt` progress callback), so coalescing across row groups no longer serializes delivery and decode starts after the first page lands. 2. `ReadManager` budgets memory by lifetime (metadata / compressed / decoded, delivered chunks included via a `ChunkInfo`) and issues all page reads of a row group from one budgeted queue, replacing the `ColumnDataPrefetch` stage and per-subgroup read issue. 3. The page cursor moves from `Reader::ColumnChunk` to `Reader::ColumnSubchunk`, letting several subgroups of a row group decode concurrently when the file has a page index; delivery order is unchanged. Related: #2275 Related: #2266 Related: #2235 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…k size a setting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…he cooperation phase Adds problem items 6-8 (depth bounded by files open, two coalescing regimes, filesystem-cache amplification of random reads), extends the issue queue to the index stages with a bytes-in-flight target fitted from measured bandwidth x RTT, replaces the static seek threshold with a source-aware coalescing cost model backed by a cache oracle, and adds Phase 2b (cross-file metadata prefetch) and Phase 2c (cache cooperation: honour per-query alignment and background-download settings on `readBigAt`, random-access segment alignment, `getCachedRanges`, page cache block size). Phase 3 is demoted behind 2b/2c. Appendix A records the measurements. The plan gets an amendments note: Tasks 4-9 will be re-cut after Phase 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…`ReaderExecutor` Upstream is replacing the read-buffer chain below `readBigAt` with the `ReaderExecutor` (issue ClickHouse#102282, PR ClickHouse#103706 and its slices). §4.2c no longer proposes changes inside the legacy cache buffers: the former items C/D/E move into the executor, the legacy-path bug fixes A/B stay as Antalya-only interim work, the planner is written against a `ReadTarget` seam (`readBigAt` today, `setRequestMap` / batched `readAt` on the executor), and the measurements owed upstream are listed. Rollout order and the plan's amendments note follow. Related: ClickHouse#102282 Related: ClickHouse#103706 Related: ClickHouse#115816 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…2, defer Phase 3 Tasks 4-6: legacy-path filesystem-cache fixes (`readBigAt` honours the per-query boundary alignment; `FileSegmentsHolder` honours `filesystem_cache_allow_background_download`) and the interim coalescing rule (`input_format_parquet_max_read_amplification`, `input_format_parquet_coalesce_gap_bytes`). Tasks 7-10: lifetime pools, honest memory cap, `Prefetcher::ReadStats`, and the issue controller that pre-issues index and page reads for all row groups under a bytes-in-flight target. Task 11: vig-test validation and hand-over to Altinity PR #2275. Former Phase 3 tasks move to a deferred section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…ting for the whole task Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…t correctly (true means stop) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…ad of a single threshold Address review findings on the previous commit: - Don't call `publishBytesReady` for the single-cell zero-copy path in `runTask`: it was publishing full readiness before the task's `Running` -> `Done` CAS, letting a waiter observe `Running` with enough `bytes_ready` and fall through to `getRangeData`'s `task->buf` return over an empty buffer (`task->buf` is never filled for that path, only `cached_region` is). The unconditional `notify_all` after the CAS still wakes waiters once the task is genuinely `Done`. - Replace the single `min_waiting_threshold` slot with a `Task::waiters` counter: the old scheme lost a live registration whenever two waiters shared a task (a `compare_exchange` from a second, smaller `need` clobbered the first waiter's value, and the producer's blanket reset on satisfying the smaller one deregistered both). Each waiter now increments/decrements its own presence and re-checks its own `need` after every wakeup; the producer just checks whether the counter is nonzero before notifying. - Use `memory_order_seq_cst` for the store/load pair on both the producer (`publishBytesReady`) and waiter (`waitForBytes`) sides of the handoff instead of plain acquire/release, which is not sufficient to prevent the store and the load from being reordered ahead of each other on two different atomics (a missed wakeup, not a correctness bug on its own, but the previous comment overstated the guarantee). - Correct the comment claiming `readBigAt`'s progress callback is cumulative for the whole call -- it restarts near zero on every `ReadBufferFromS3` retry attempt; `publishBytesReady`'s guard against non-increasing values is what makes that safe, and now says so. - Note in `publishBytesReady` why its read-modify-write of `bytes_ready` isn't racing with another writer (only the one thread running `runTask` for a given task ever calls it). - Wrap code names in backticks in the touched comments; move an inline comment above its line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…letes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…BigAt path `CachedOnDiskReadBufferFromFile::readBigAt` called `FileCache::getOrSet` without the per-query `boundary_alignment`, unlike the sequential path (`nextFileSegmentsBatch`), which already forwards `info.cache_settings.boundary_alignment`. As a result, random-access reads (used by the Parquet v3 reader for column-chunk reads) always fell back to the cache's own configured alignment, ignoring `filesystem_cache_boundary_alignment` entirely for small reads in the middle of a large aligned segment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…ially read segments `FileSegmentsHolder::reset` (called on destruction, e.g. after every `readBigAt` random read) hard-coded `allow_background_download=true` when completing the front segment, so a query with `filesystem_cache_allow_background_download = 0` still queued the rest of a partially read segment for background download. `FileSegmentsHolder` now stores the flag (defaulting to `true`, preserving the existing behaviour and the rationale in `reset`'s comment) and `CachedOnDiskReadBufferFromFile` sets it from `info.cache_settings.allow_background_download` right after each holder is created (`nextFileSegmentsBatch` and `readBigAt`). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
… on remote storage Adds two interim knobs to the Parquet v3 reader's range-coalescing loop in `Prefetcher::pickRangesAndCreateTaskIfNotExists`: `input_format_parquet_coalesce_gap_bytes` (default 2 MiB, capped by the storage's min-bytes-for-seek) bounds how large a gap between two needed byte ranges the reader will read through to serve both in one request, and `input_format_parquet_max_read_amplification` (default 4) stops a coalesced read from growing once its span exceeds that multiple of the useful bytes it covers. Both are interim until the storage layer's cost model replaces this heuristic. On object storage the useful gap is about one round trip's worth of bandwidth (~2 MiB); on a warm cache, without the cap, a single small column chunk could otherwise drag megabytes of unrelated row-group data through the read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…coded) instead of by stage Replaces the per-ReadStage memory_target_fraction/memory_usage accounting in Parquet::ReadManager with three lifetime-based pools (MemoryPool::Metadata, Compressed, Decoded), each an atomic<ssize_t> plus a per-reader limit from SharedResourcesExt::getLimitsPerReader. Every ReadStage maps to a pool via the new constexpr poolOf. Thread scheduling fractions per stage are unchanged. Adds `input_format_parquet_compressed_memory_fraction` (default 0.35): share of the memory budget held as compressed data pages in flight, bounding how far ahead of decode the reader reads. Metadata gets a fixed 5%; the rest goes to decoded columns, including chunks already delivered to the pipeline. `input_format_parquet_prefetch_memory_fraction` is superseded and kept declared but inert for memory, for compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
… budgeting Review of the previous commit found that merging BloomFilterHeader / BloomFilterBlocksOrDictionary / ColumnIndexAndOffsetIndex / OffsetIndex into one Metadata pool could deadlock: dictionary-page prefetch is charged at BloomFilterBlocksOrDictionary and released only at ColumnData, so long-lived Metadata bytes could block a row group from ever reaching ColumnData, while is_privileged_task only exempted row groups already at ColumnData or later, and flushMemoryUsageDiff only re-checked the one stage whose own by_stage went negative, never a sibling stage sharing the same pool. Fixes both: - is_privileged_task: the lowest incomplete row group is now privileged unconditionally below ColumnData (pools are shared across stages, so it must always be able to advance through the metadata stages); the read_ptr == delivery_ptr condition still applies from ColumnData onward. - flushMemoryUsageDiff: when freeing memory unblocks stage i's pool, wake every stage sharing that pool, not just i, via a bitmask (deduplicated against tasks already scheduled elsewhere in the same flush). Fixing the scheduling loop's stage range for this (Deliver must stay excluded) itself fixed a `stage_idx < ReadStage::Deliver` assertion this change first introduced, caught by the covering-test rerun. Also: corrected the SettingsChangesHistory entry for input_format_parquet_compressed_memory_fraction (the previous split gave compressed read-ahead 45% of the budget, 0.75 x 0.6, not 20%); reworded input_format_parquet_prefetch_memory_fraction's doc to say it's ignored rather than repeating a range check that no longer exists; split the leak check in read() into one pass over the three pools (named in the message) plus a per-stage batch/task pass; added a static_assert tying NUM_MEMORY_POOLS to MemoryPool's enumerator count; added backticks around MemoryPool/poolOf/pool_usage/ColumnDataPrefetch in the comments this task introduced. Kept at Metadata = 0.05 per the spec; budget sizing is tuned in a later task. Known interim gap, accepted for this task: the Decoded pool's effective share roughly doubles (spec's ~0.30 baseline vs today's ~0.60, since 0.05 + compressed_fraction default 0.35 leaves 0.60 for Decoded) because delivered-but-unconsumed chunks are not yet charged to it; the next task adds ChunkMemoryInfo to close this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…until the pipeline drops them Task 7's `MemoryPool::Decoded` accounting released a row subgroup's `ColumnData`-stage charge as soon as it transitioned to `Deliver` (in `finishRowSubgroupStage`), even though the decoded `IColumn` memory stays alive inside the `Chunk` handed back to the pipeline. With a slow downstream consumer, this let the scheduler keep admitting new decode work indefinitely: the `Decoded` pool looked empty for chunks that were, in fact, still fully resident and simply waiting to be consumed. Fixes this with a new `ChunkMemoryInfo` (`ChunkInfoCloneable`) attached to every chunk `read()` returns, sized by `chunk.allocatedBytes()` and backed by a `shared_ptr<atomic<ssize_t>>` (`ReadManager::delivered_bytes`) that outlives the reader. Its constructor/copy-constructor add the charge, its destructor removes it, so every live copy of the chunk (and its `ChunkInfo`) holds exactly one charge. `scheduleTasksIfNeeded` and `flushMemoryUsageDiff` add `delivered_bytes->load()` to the `Decoded` pool's `memory_usage` before calling `checkTaskSchedulingLimits`, so admission of new `ColumnData`/`Deliver` work now sees the true resident size, including chunks the pipeline hasn't consumed yet. No wake-up from the chunk destructor is needed: the existing `is_privileged_task` rule already guarantees progress once the pool is over budget. `collectDeadlockDiagnostics` now also prints `delivered_bytes` next to the pools. RED (pre-change, task-7 HEAD `5daead9cea3`, buildId `621A879650F59BFDB9A1E1BEB2C83CB35F538B46`): the new test (`05030_parquet_memory_cap_honest`, 64 row groups of 16384 rows each, 8 String columns, `input_format_parquet_memory_high_watermark = 128 MiB`, a `sleepEachRow`-throttled consumer, `max_threads = 8`) measured `memory_usage = 300663943` bytes (~300.7 MB), above the `2 x 128 MiB = 256 MiB` bound the test asserts. GREEN (post-change, buildId `E57E423B5DAA117C5A59B1CDD093D5ECD3740BAD`): the identical query measured `memory_usage = 213428791` bytes (~213.4 MB), under the bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…ent, mechanism-pinning test) Addresses three review findings on the prior commit: - `ChunkMemoryInfo` now deletes its copy-assignment operator: the implicit one would overwrite `counter`/`bytes` without adjusting either charge, silently corrupting the `Decoded` pool's accounting. Also documents that clones (query result cache, `CopyTransform` fan-out, etc.) each carry their own charge -- a deliberate conservative over-count, not a bug. - `05030_parquet_memory_cap_honest.sh`'s query changes from `SELECT count() ... WHERE sleepEachRow(...) = 0 AND <8 length() conjuncts>` to `SELECT count(), max(length(s1)), ..., max(length(s8)) FROM ... WHERE sleepEachRow(...) = 0`. The old query forced column decoding only via the WHERE clause; if a future optimization pushes that filter into the reader's own PREWHERE and drops filter-only columns from the delivered chunk once evaluated, `count()` alone needs none of them, `allocatedBytes()` would collapse to near zero, and the test would keep passing while measuring nothing. The aggregate's `max(length(s<N>))` genuinely needs each column's decoded values as *output*, so the columns must still reach `Deliver` regardless of how the filter itself gets executed. Also added a second, independent assertion (`read_bytes > 0` and `result_rows = 1`, both from `system.query_log`) as a cheap sanity check that the query actually read column data and produced its row, so a change that made the query trivially cheap couldn't make the memory assertion pass by measuring nothing. - Ran `05030_parquet_memory_cap_honest` 5x against the fixed binary (buildId `C99DBD2010DA84B1205F990205E6E3AB762318A4`) to check the bound's headroom isn't a one-sample fluke: `memory_usage` = 210899927, 209723678, 210825527, 210819119, 210820959 (all `OK`, tight ~0.5% spread). The review also asked to loosen the assertion from `2 x high_watermark` to `3 x high_watermark`. Did not make that change: on this same test shape, reverting just the `Decoded`- pool fix and rerunning the identical query measures `memory_usage` in the ~2.2-2.3x range (single sample: 304884165, i.e. 2.27x `134217728`), which is *below* `3 x high_watermark` -- so a 3x bound stops failing on the exact pre-fix code this test exists to catch, eliminating the regression test's purpose rather than just widening its margin. Confirmed this isn't a shape-specific fluke: a coarser shape (32 row groups of 32768 rows) pushes both pre-fix (~427.76 MB, 3.19x) and post-fix (5 runs, ~353-372 MB, 2.63-2.77x) up together, keeping a similarly tight ratio between them rather than widening the gap -- so no row-group granularity tried supports a 3x bound while still discriminating. Kept `2x`, which sits with ~20% margin below the pre-fix measurement and ~21% margin above the highest of the 5 post-fix runs at the shipped shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…cher `Prefetcher` now tracks `bytes_in_flight` (sum of `length` of `Scheduled`/`Running` tasks) and fits an EWMA of round-trip time and bandwidth from the timing of each source read: a `Stopwatch` on `Task` is restarted right before the read, and `publishBytesReady` records `first_byte_us` on the first callback. `readStats`, `bytesInFlight` and `targetBytesInFlight` expose this so a later change (the read issue controller) can decide how many planned reads to keep outstanding. Adds `ParquetReadFirstByteMicroseconds` and `ParquetReadTransferMicroseconds` profile events, incremented once per completed source-read task (excluding the zero-copy `cached_region` path, which has no data movement to time). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…cher stats Review fixes for the read-stats commit: - I1: `readSync` always makes one final unconditional `on_progress(n)` call, so on transports whose `readBigAt` never reports mid-transfer progress (local `pread`, Azure, HDFS) `publishBytesReady` fired exactly once, at completion, making `first_byte_us` land within a microsecond of `total_us` and producing absurd `length / ~1us` bandwidth samples that dominated the EWMA. Added `transport_progress`, set only when the callback reports a partial count (`copied < task->length`); bandwidth samples and the first-byte/transfer split now require it, falling back to `rtt_sample_us = total_us` with no bandwidth sample otherwise. - I2: the multi-cell `readBigAtRetainCells` path (no `cached_region`, memcpy into `buf`, single `publishBytesReady(task, length)` call) passed the old `!cached_region` stats gate and folded a cache memcpy into the fitted bandwidth. Replaced the gate with `served_from_cache`, set for both the single- and multi-cell cache branches. - Minor: `first_byte_us` floored to 1 so 0 stays an unambiguous sentinel; `updateReadStats` moved past the state CAS and `notify_all` so waiters aren't held up by the stats mutex; `targetBytesInFlight` clamps the bandwidth*rtt product before the `size_t` cast; added `chassert(refcount > 0)` in `scheduleTask` documenting why the fetch_add can't race a concurrent drop to zero; comment wording fixes in `Prefetcher.h`; test's vacuous `>= 0` assertion replaced with a duration-scaled sanity bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…tes-in-flight target Reads used to be issued stage by stage, one row group at a time: a row group's bloom filter headers, then its column and offset indexes, then the data pages of one subgroup, each group waiting for the previous one. With a few tens of KB per index read and one round trip each, that leaves a single file with about one read outstanding at a time, which is what limits cold object-storage reads (measured ~25-29 GETs in flight per node, where the fitted bandwidth times round-trip time asks for ~100 MB). `ReadManager` now plans reads ahead of the stage that consumes them and issues them from one FIFO under a bytes-in-flight target. `init` plans the index reads of *every* surviving row group in delivery order; a row group's data-page reads are planned for all of its subgroups at once as soon as the offset indexes of the first step's columns are decoded (`PlannedRead`, `enqueueRowGroupIndexReads`, `enqueueRowGroupPageReads`). `pumpIssueQueue` issues queued entries in order while `Prefetcher::bytesInFlight` plus the entry's bytes stays under `max(Prefetcher::targetBytesInFlight, input_format_parquet_min_bytes_in_flight)` and the entry's memory pool (`poolOf`) has room, and is called from `flushMemoryUsageDiff` so that landing reads and freed pages let the next entries through. The first incomplete row group is privileged: its entries are always issued, wherever they sit in the queue, so progress never depends on the budget. The stage machine itself is unchanged, and it stays the demand path: a stage that needs handles the planner queued takes them out of the queue (`takeQueuedReads`) and issues them itself, so no read ever waits for the budget when it is actually needed, and a handle is never started by two threads at once. Reads planned for a row group are dropped (`dropQueuedReads`) before its `ColumnChunk`s are cleared, since entries point into `ColumnChunk::data_pages`. Later PREWHERE steps keep planning per subgroup, because which pages they need depends on their own PREWHERE result. Adds `input_format_parquet_min_bytes_in_flight` (default 64 MiB), the profile events `ParquetPlannedReads` and `ParquetIssueQueueStalls`, and the queue length and current target to the deadlock diagnostics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…f, and fix a racy prefetch handover Review fixes for the issue controller. Only the reads the reader cannot make progress without now bypass the budgets (`ReadManager::isPrivilegedRead`): the index reads of the row group that has to be delivered next, and the data pages of the one subgroup it reads next. Before, every entry of the first incomplete row group was privileged, so on a single-row-group file the whole row group's first-step pages went in flight regardless of `input_format_parquet_memory_high_watermark`, undoing the honest memory cap. The bypass isn't needed for the other subgroups: the stage that needs them takes them out of the queue and starts them itself (`takeQueuedReads`), so no subgroup ever waits for the pump. `input_format_parquet_min_bytes_in_flight = 0` now means "no read-ahead planning": both `enqueue` functions return immediately and every read is issued on demand, which is the behavior from before the issue controller and what `compatibility` restores. The setting's description and the settings-history entry say so, and the description also notes that values below the fitted floor of 4 x `input_format_parquet_bytes_per_read_task` have no additional effect. `ParquetIssueQueueStalls` is now counted once per pump that got nothing out, instead of once per entry it had to skip, so it reads as "times read-ahead had to wait". `ParquetPlannedReads`' description says that it counts only the read groups the controller itself issued. Documents the invariant that makes `ColumnChunk::dictionary_and_whole_chunk_planned` safe -- the subgroup that claims a column's dictionary and whole-chunk handles always reaches `ColumnDataPrefetch` and drains its entry, because `rows_pass` can only be zeroed by `applyPrewhere` at a later step than the planner plans -- and asserts it (`hasQueuedReads`) at the `rows_pass == 0` early return that would otherwise leave an entry behind. Also notes why the first planning hook deliberately doesn't set `page_reads_planned`, and that the entries' `bytes` are requested-range lengths while coalesced tasks may span gaps. Fixes a crash this exposed, outside the Parquet code: `AsynchronousBoundedReadBuffer::readBigAt` consumes the pending small-object prefetch from a `mutable std::future` with no synchronization. The Parquet reader calls `readBigAt` from its io pool, so with reads issued ahead two calls would both pass `valid()` and call `get()` on the same future (or one would reset it under the other), which is a use-after-free of the future's shared state -- a reproducible segfault in `04267_s3_parquet_in_subquery`. The claim-and-copy phase now runs under a mutex every `readBigAt` passes through, which also restores the exclusion the code intended between `impl` and the in-flight prefetch task; no storage read is issued while holding it. Test `05032_parquet_issue_controller` gains a `= 0` arm (identical results, `ParquetPlannedReads = 0`) and a second stall arm that exercises the bytes-in-flight target itself (a small `input_format_parquet_bytes_per_read_task` lowers the fitted floor under one subgroup's pages), alongside the existing memory-pool one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…nd fix the read-path review findings `CachedOnDiskReadBufferFromFile::readBigAt` passed the progress callback the bytes copied from the *current* file segment instead of the running total. `SeekableReadBuffer::readBigAt` documents the callback as reporting that `to[0..m-1]` has been filled, "with increasing m", and every consumer reads it that way: `Prefetcher::publishBytesReady` drops non-increasing values (a guard for `ReadBufferFromS3`, which restarts its count on each retry attempt), `ParallelReadBuffer` assigns `bytes_produced` from it, and `AsynchronousBoundedReadBuffer::readBigAt` shifts a nested buffer's progress by the prefix it already served precisely to keep the total cumulative. With per-segment deltas a Parquet read task's `bytes_ready` could never advance past one cache file segment, so partial readiness was inert on the filesystem-cache path. Measured on a 55 MB single-task read through a cache with 1 MiB segments: `ParquetPartialReadsServed` 0 before, 1-2 after. Also from the whole-branch review: - `SettingsChangesHistory`: `input_format_parquet_compressed_memory_fraction`'s `previous_value` is now `0.45`, the share the old per-stage split really gave to compressed read-ahead, so `compatibility` restores that proportion; `input_format_parquet_max_io_threads`'s reason spells out the derivation and says that `compatibility` cannot restore the old `max_download_threads`-sized pool, and how to restore it by hand. - `input_format_parquet_prefetch_memory_fraction` was inert since the memory pools replaced the per-stage fractions. Moved to `OBSOLETE_FORMAT_SETTINGS` (so it still parses), removed from `FormatSettings` and `FormatFactory`, and noted as obsolete in the current version's history block. - `Prefetcher::init` rejects `input_format_parquet_max_read_amplification` values in `(0, 1)` with `BAD_ARGUMENTS`: a coalesced read always spans at least the bytes it serves, so such a value only looks like a typo for `0` (disabled). - `pumpIssueQueue` and `takeQueuedReads` call `startPrefetch` before erasing the entry from `issue_queue`, so an exception there cannot leave handles that nobody started and nobody can reach. `takeQueuedReads` therefore starts the reads itself instead of handing them to the caller, which also closes the window in `scheduleTask` between draining an entry and issuing it. - `ParquetIssueQueueStalls` counts a pump that ran into a full budget even if it then issued a privileged read: read-ahead was blocked either way. - Comments: `ColumnDataPrefetch` is kept, not removed; `pumpIssueQueue`'s doc now matches `isPrivilegedRead` (index entries plus the `read_ptr` subgroup of the first incomplete row group, not the whole row group); the pool cap is checked against `PlannedRead::bytes`, which sums request lengths, while the tokens charged are `length` times the task's `memory_amplification` - a bounded overshoot, not the same quantity. Backticks added around `MemoryPool` and `poolOf(ReadStage)` where they were missing. - Spec: `CachedOnDiskReadBufferFromFile` added to the 4.1 transport list, and 2's "no behaviour change for local files" replaced by the three changes that do happen at local defaults (amplification cap active, larger IO pool, 64 MiB of planned read-ahead). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…n the read-path tests' settings `05028_parquet_cache_readbigat_alignment` gets a third arm: a two-row-group, uncompressed file with 8 MiB data pages, read through `cache_for_readbigat` as one `input_format_parquet_bytes_per_read_task`-sized task, asserting `ProfileEvents['ParquetPartialReadsServed'] > 0`. Every requested range is then larger than the cache's 1 MiB `max_file_segment_size`, so the assertion holds only when `CachedOnDiskReadBufferFromFile::readBigAt` reports a running total: with per-segment deltas the monotonic guard in `Prefetcher::publishBytesReady` pins a task's readiness to one segment and the count is exactly 0. Its tight byte-count bound is relaxed from `<= 2x` to `<= 3x` (still separated from the `>= 4x` arm) with a comment on why some overshoot is expected: a 64 KiB per-query alignment rounds up every tiny column chunk, and the reader's coalescing adds the gaps it reads through. `05031_parquet_read_stats` sets `use_page_cache_for_disks_without_file_cache = 0`: a page-cache-served read takes the zero-copy `readBigAtRetainCells` path, which is deliberately excluded from the fitted read stats, so the asserted events would read 0. `05032_parquet_issue_controller` pins `input_format_parquet_memory_high_watermark` in the `no_stall` arm, which asserts that read-ahead never had to wait and so needs a deterministically sized compressed pool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: UnamedRus <dtitmoav@gmail.com>
UnamedRus
force-pushed
the
parquet-v3-read-sizing
branch
from
August 28, 2026 15:03
fe4a3ce to
6dad4c2
Compare
`bytes_in_flight` was incremented in `Prefetcher::scheduleTask`, i.e. when a task was handed to the IO pool, so a task merely queued behind busy pool threads counted against the read-ahead target. The target therefore bounded how much work was queued, not how much the storage was working on: the pool executes `io_threads` reads at a time no matter how many are queued behind them, so once the queue held the target's worth of bytes the controller stopped planning even though the pipe had room. Count the bytes of tasks that are actually being read instead: `bytes_executing` is incremented at the `Scheduled` -> `Running` CAS in `runTask` and decremented when that read finishes, and `bytesInFlight` reports it. The old counter stays as `bytesQueued` for the `pools:` diagnostic line. A task dropped while still `Scheduled` never enters the new counter, so `decreaseTaskRefcount` has nothing to undo. Measured on a file whose reads are 72 KiB each: the phantom "budget full" state disappears, so the 2759 planned reads are all issued (they were not before) and `ParquetIssueQueueStalls` on that query goes from 175 to 0. Raising `input_format_parquet_min_bytes_in_flight` now moves the counters monotonically, which it did not before. Wall time is unchanged - the number of reads executing at once is bounded by the IO pool, and that is what the target should have been measuring all along. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two changes to `input_format_parquet_max_read_amplification`. Its default moves 4 -> 8. The bound is checked on every intermediate state of a read as coalescing grows it one neighbouring range at a time, so a tight value also rejects merges whose *finished* read would be well inside the bound: extension starts from one small chunk, and the first candidate across a gap looks like a 10-20x read even when the completed row-group read lands at 2.5x. On a query reading six narrow columns of a wide table, `4` split each row group into 1.6x as many requests as no bound at all and made the decoding threads wait 3.6x longer, while `8` matched the unbounded request count and still read 37% fewer bytes than the unbounded reader across a 23-query benchmark. Values from 6 to 16 measured the same there; below 6 the request count climbs. The new `input_format_parquet_read_amplification_floor_bytes` (default 256 KiB) exempts reads whose absolute waste is small. A ratio alone cannot see that five columns of a 25 KiB file lie a few KB apart: reading the file in one request wastes ~20 KB, which the ratio scores as a bad read. On an Iceberg table of 17554 mostly-tiny files, the ratio bound alone cost 65% more requests and 45% more wall time to save 13% of the bytes; with the floor the same query matches the reader with no bound at all (9968 requests, 576 MiB, 14.5 s against the bound's 16440, 502 MiB, 19.9 s), while a query whose reads waste megabytes per useful range keeps the bound's full effect - 332 MiB read down to 11 MiB. `0` restores the unconditional ratio, which is what `compatibility` selects for older versions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coalescing stopped at any gap wider than `input_format_parquet_coalesce_gap_bytes`, whether or not the gap was already in memory. On a partially warm page cache that is the common case: a cached block sitting between two needed ranges split the read in two, and each half then paid a full round trip for its uncached part. The bytes were already there; only the coalescer did not know. `CachedInMemoryReadBufferFromFile::isBigRangeCached` answers whether every cache block covering a range is resident. Unlike the existing `isContentCached`, it touches no buffer state - no seek, no `chunk` population - so it follows the same thread-safety rules as `readBigAt` and can be called from the planning thread. It is advisory: a block may be evicted between the probe and the read, which only costs the bytes the merge was trying to save. The coalescer probes the gap a candidate merge would read through, and a cached gap neither fails the gap threshold nor counts towards `input_format_parquet_max_read_amplification`, since reading through it costs a memcpy rather than a request. The credit is applied only when the neighbouring range is actually merged in, and gaps larger than `input_format_parquet_bytes_per_read_task` are not probed at all, so the probe cost stays proportional to the read it may enable. Sources other than the page cache leave the probe null and coalesce exactly as before. Measured with two of five columns warm: 21% fewer bytes and 24-28% less wall time; with three warm, 84% fewer bytes and 38-69% less wall time. The reads also get larger rather than merely fewer, which is what lets the page cache coalesce its own missing blocks underneath. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`populateBlockRange` issued one request per run of consecutive missing blocks, so a partially warm cache - the normal steady state - degenerated into a request per block: on a file half of whose blocks were resident, a five-column read took 947 requests instead of 180, and at a small IO pool caching half the data was *slower* than caching none of it (9.3 s against 7.8 s). A run of missing blocks may now read through a short island of cached blocks so that two runs cost one request. The island's bytes are fetched and dropped - the resident cells are never overwritten - and counted into `PageCacheReadThroughBytes`. A run never ends on a cached block, and it still respects `page_cache_max_coalesced_bytes`. How much may be spent is bounded twice, because the two costs have different owners. The time cost is bounded by the cache itself: it times its own source reads (a progress callback separates time-to-first-byte from transfer), fits bandwidth and round-trip time, and allows an island of at most `bandwidth * rtt` - the bytes that move in the time the avoided request would have spent waiting. That must be measured here rather than in the Parquet reader, which sees no bandwidth samples at all once a cache is in front of it. The byte cost is bounded by the reader: `Prefetcher` publishes a share of the request's missing bytes, `clamp(4 / io_threads, 0.05, 1)`, because whether extra bytes are cheap depends on how many reads are in flight - with an idle pipe they are nearly free, with a saturated one each displaces a useful byte - and only the reader knows that. Fitting the same thing from inside the cache does not work: per-read bandwidth already includes the sharing, so comparing it to the peak seen in the same query yields ~1 at every concurrency. Measured on a five-column read of a 40-column file, half its blocks resident: requests 942 -> 201 and wall time -27% at 4 IO threads, -41% with three columns warm; at 64 threads the reader's share drops to a sixteenth, the read-through switches itself off, and the query is 9% faster than with no read-through at all rather than 32% slower. Fully cold and fully warm reads spend no read-through bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test asserted `ParquetReadTasks * 2 < tasks_no_floor`, which the measured numbers miss by two reads: without the floor the query issues 792 reads of 3.74 MB, with it 397 reads of 3.91 MB, and 397 * 2 = 794. Assert a third fewer reads instead, which is the effect the floor is there for and does not depend on exactly how the writer lays out the row groups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rework of the Parquet v3 read path for object storage, replacing the earlier read-sizing knobs in this PR with a measured redesign (spec:
docs/superpowers/specs/2026-08-27-parquet-readpath-redesign.mdon the branch).Problem. On S3-backed Iceberg tables the reader was latency-bound: a coalesced read task blocked delivery until its last byte arrived; a 640-byte column could drag 3.5 MiB of unrelated bytes per row group through the coalescer (32 GB through the cache disk for 450 MB of pages on one bench query); the filesystem cache ignored the per-query
boundary_alignmenton the random-access path and always queued background fill of partially read segments (450 MB requested → 35 GB downloaded); memory was budgeted per pipeline stage so the high watermark was not a cap (delivered chunks were never charged); and reads were issued one row group at a time per file (~25–29 GETs in flight per node regardless of settings).What changes.
Prefetcherserves a coalesced read's bytes as they arrive (Task::bytes_ready, progress callback fromreadBigAt); decode starts on the first row group of a task while later bytes stream. New eventsParquetPartialReadsServed,ParquetReadTasks,ParquetReadTaskBytes.Coalescing is bounded:
input_format_parquet_max_read_amplification(default 4×) andinput_format_parquet_coalesce_gap_bytes(default 2 MiB on remote storage; measured sweet spot vs the 4 MiBremote_read_min_bytes_for_seek).Filesystem cache:
readBigAthonours the per-queryfilesystem_cache_boundary_alignment;FileSegmentsHolderhonoursfilesystem_cache_allow_background_download.Memory is budgeted by lifetime —
Metadata/Compressed(input_format_parquet_compressed_memory_fraction, default 0.35) /Decodedpools — and delivered chunks stay charged toDecodeduntil the pipeline drops them (ChunkMemoryInfo).input_format_parquet_prefetch_memory_fractionis kept for compatibility but no longer sizes memory.Reads are pre-issued for all row groups of a file (index reads at init, page reads once the offset index is known) from one queue, bounded by a bytes-in-flight target fitted online from measured first-byte time and bandwidth (
Prefetcher::ReadStats; floorinput_format_parquet_min_bytes_in_flight, default 64 MiB). EventsParquetPlannedReads,ParquetIssueQueueStalls,ParquetReadFirstByteMicroseconds,ParquetReadTransferMicroseconds.IO pool derived from the query (
max(max_download_threads, min(max_parsing_threads, 16)), overrideinput_format_parquet_max_io_threads);input_format_parquet_bytes_per_read_task.Read amplification is bounded by a ratio and a floor:
input_format_parquet_max_read_amplification(default 8) only applies once a read wastes more thaninput_format_parquet_read_amplification_floor_bytes(default 256 KiB). A ratio alone cannot see that five columns of a 25 KiB file lie a few KB apart, and splitting such a read costs a round trip to save nothing.bytesInFlightcounts bytes being read, not bytes queued: a task waiting for an IO thread no longer consumes the read-ahead target, which previously madeinput_format_parquet_min_bytes_in_flighta bound on queue length rather than on read depth.The coalescer reads through gaps the source already holds in its page cache, and the page cache itself reads through short islands of cached blocks; the budget for the latter is fitted by the cache from its own bandwidth and round-trip time and capped by a share of the request that the Parquet reader publishes from its IO concurrency. Event
PageCacheReadThroughBytes.Behaviour changes at defaults, also for local files: read amplification cap (8×, and only above 256 KiB of waste) applies everywhere (gap threshold for local files stays 8 KiB); IO pool 4 →
min(max_parsing_threads, 16); up toinput_format_parquet_min_bytes_in_flight(64 MiB) of index/page reads issued ahead per file. Pre-issue reads offset indexes and bloom-filter headers of row groups the column index later prunes and pages for PREWHERE-filtered rows — bounded extra bytes visible in the A/B.input_format_parquet_min_bytes_in_flight = 0disables planning (previous behaviour).input_format_parquet_prefetch_memory_fractionis obsolete.Known limitations to measure (not design flaws): the target is scaled by the query-wide IO pool per file; the
Metadatapool share is a fixed 0.05 — on very wide files pre-issue may stall on it (ParquetIssueQueueStalls). Fitted read stats exclude page-cache-served reads. Found and fixed en route:AsynchronousBoundedReadBuffer::readBigAtconsumed its pending prefetch future unsynchronised (segfault under concurrent positioned reads; from the ClickHouse#110263 backport) — to be reported upstream, together withFileSegmentsHolder::reset's non-advancingcatchloop.Measured. Single node, real S3 Iceberg, release binaries on both sides (base image vs the CI
build_amd_releaseof this head), 23 IcebergBench queries, arm order alternated per query, results byte-identical on all 23.The headline depends on which caches are warm, so all three states are given. The Iceberg metadata cache (
use_iceberg_metadata_files_cache, on by default) is the one that decides:The fully cold row is not a read-path result: 72% of a cold query on this dataset is the serial Iceberg manifest phase (27.7 s of the 37.6 s, 731 metadata files across the 23 queries, every pipeline thread blocked on it), which this PR does not touch. The part the read path owns — time the decoding threads spend blocked on file data — is 2.3 s of that 37.6 s, and the branch cuts it 35%, which is the -2%. Warm the metadata layer alone and the manifest phase disappears (37.6 s -> 10.7 s for base by itself), leaving the read path visible at -20%. The three queries that looked like cold regressions in a single sample are within noise at five cold reps (q04 1.56 -> 1.43 s, q18 1.48 -> 1.43 s, q02 1.46 -> 1.48 s).
Across all three states the branch reads 37-38% fewer bytes (1767 -> 1099 MiB metadata-warm; 1784 -> 1118 MiB cold) for ~12% more requests, with peak memory unchanged. Per-query wins metadata-warm: q20 -40%, q06 -32%, q21 -29%, q08 -28%, q04 -27%, q13 -22%, q05/q17 -20%. Read amplification (
ReadBufferFromS3Bytes / ParquetReadTaskBytes) drops 41.5x -> 1.4x on q20, 6.2x -> 1.7x on q14, 4.3x -> 1.1x on q06, 3.1x -> 1.2x on q18.ParquetPartialReadsServedis 11-689 per query, so partial readiness serves decodes on the object-storage path;ParquetIssueQueueStallsis 0 everywhere, so theMetadatapool share never throttled pre-issue on this shape.A 17,554-file Iceberg table (437 columns, ~70 GiB, mostly ~25 KiB files) is what produced
input_format_parquet_read_amplification_floor_bytes. With the ratio bound alone the branch was 45% slower than base on a five-column month slice (20.1 s vs 13.9 s, 16,440 vs 9,968 requests, 502 vs 576 MiB): the bound split each small file's read three to four ways to avoid ~20 KB of waste. At the shipped defaults the branch matches base there (13.74 s, 9,968 requests, 576 MiB against base's 14.03 s, 9,968, 576), and setting the floor to0reproduces the old behaviour exactly (18.82 s, 16,440 requests, 502 MiB).input_format_parquet_max_io_threadsmakes no difference on this shape — it is bound by file count, not read depth.A single large file (459 and 918 row groups on S3, plus a 200-file single-row-group control) is where the redesign is largest. The base reader reads such a file effectively serially — mean concurrent GET requests 0.98, ~90 MiB/s — while this branch reaches 19-70 concurrent requests and 1.1-1.2 GiB/s: 10-14x less wall time. Base does not improve when given the same IO pool (
max_download_threads4 -> 128 changes nothing), so the concurrency is what the redesign creates and the pool size only throttles it. On this shape the bytes-in-flight controller specifically is neutral —input_format_parquet_min_bytes_in_flightat 0, 64 MiB and 256 MiB are indistinguishable in wall time and concurrency, while the non-zero values cost ~2.3x peak memory — and the IO pool size is the lever that matters (3.6 s -> 1.2 s from 16 to 128 threads on the read-bound query).A partially warm page cache is what the two cache-side commits address. Cache hits queued behind ~30 ms network reads, and cached blocks fragmenting the coalescing, used to make a half-cached file slower than an uncached one at a small IO pool (9.3 s vs 7.8 s). With both changes it is 5.8 s, requests drop 942 -> 201, and at a deep pool the read-through disables itself rather than spending bytes it cannot afford.
Related: #2266
Related: #2235
Related: ClickHouse#102282
Related: ClickHouse#103706
Related: ClickHouse#115816
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes to CHANGELOG.md):
Parquet reader on object storage: bytes of a coalesced read are decoded as they arrive; read coalescing is bounded by
input_format_parquet_max_read_amplificationandinput_format_parquet_coalesce_gap_bytes; reads for all row groups of a file are issued ahead under a bytes-in-flight target (input_format_parquet_min_bytes_in_flight); reader memory is budgeted by lifetime (input_format_parquet_compressed_memory_fraction) and delivered chunks stay charged until consumed; the filesystem cache honoursfilesystem_cache_boundary_alignmentandfilesystem_cache_allow_background_downloadon the Parquet random-access path. New settingsinput_format_parquet_max_io_threads,input_format_parquet_bytes_per_read_task,input_format_parquet_read_amplification_floor_bytes(a read wasting little in absolute terms is exempt from the amplification bound); coalescing and the page cache now read through data that is already cached rather than splitting the read around it; new profile eventsParquetPartialReadsServed,ParquetReadTasks,ParquetReadTaskBytes,ParquetPlannedReads,ParquetIssueQueueStalls,ParquetReadFirstByteMicroseconds,ParquetReadTransferMicroseconds,PageCacheReadThroughBytes.Documentation entry for user-facing changes
All new settings are documented in their
DECLAREdoc strings;input_format_parquet_prefetch_memory_fraction's doc string notes it is superseded.CI/CD Options
Exclude tests:
Regression jobs to run: