Skip to content

Lightweight Updates v2 - #103182

Merged
CurtizJ merged 103 commits into
ClickHouse:masterfrom
CurtizJ:patch-parts-sort-key-v2
Aug 17, 2026
Merged

Lightweight Updates v2#103182
CurtizJ merged 103 commits into
ClickHouse:masterfrom
CurtizJ:patch-parts-sort-key-v2

Conversation

@CurtizJ

@CurtizJ CurtizJ commented Apr 20, 2026

Copy link
Copy Markdown
Member

Changelog category (leave one):

  • Backward Incompatible Change

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

Lightweight UPDATE patch parts now use a new v2 on-disk format sorted by (sorting_key..., _block_number, _block_offset) and applied with a new merging algorithm. Peak memory is bounded by the largest equal-sort-key run instead of the full patch, and updates that cross merge boundaries no longer fall back to in-memory Join apply. Old-format patch parts remain readable. During a rolling upgrade from a version before 26.8, keep patch_parts_version = 'v1' or use the compatibility setting until all replicas are upgraded.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

CurtizJ and others added 15 commits April 16, 2026 23:13
Previously v2 lightweight updates (enable_v2_lightweight_update_patches)
fell back to v1 when the target table used an expression sort key like
`ORDER BY cityHash64(id)`, because the patch persisted the sort-key
*result* column names (e.g. `cityHash64(id)`) which don't exist as
physical columns on the main part.

Make v2 handle all sort keys — including expression sort keys — by
persisting the sort-key **expression list AST** (as SQL) in
`SourcePartsSetForPatch` and replaying its `ExpressionActions` on both
the main-side and patch-side blocks at apply time, the same way FINAL
materializes the sort-key expression over base parts
(`ReadFromMergeTree.cpp:1431`).

Changes:
 - `SourcePartsSetForPatch` persists `sort_key_expr_list_sql` + reverse
   flags (replaces the old flat `sort_key_column_names`).
 - `getPatchPartMetadataV2` parses the AST with `ParserExpressionList`,
   feeds it to `KeyDescription::getKeyFromAST`, and lets
   `KeyDescription::expression` materialize expression sort-key outputs.
 - `PatchPartInfo` now carries `sort_key_source_column_names` (physical
   inputs to read from main), `sort_key_result_column_names` (outputs
   used for the two-cursor merge), and the shared `sort_key_expression`.
 - `MergeTreeData::updateLightweightImpl` injects READ_COLUMN entries
   for the expression's required physical columns (not the result
   names), so patches store only what actually exists on disk.
 - `MergeTreePatchReaderMergeOnKey::readPatch` augments the patch block
   with the expression once; `applyPatchMergeOnKey` augments a clone of
   the main block the same way before comparing.
 - `isV2LightweightUpdateUsable` no longer rejects expression sort keys.

Smoke test `04102_lightweight_update_v2_smoke.sql` now exercises an
expression sort key (`ORDER BY intHash32(id)`) end-to-end under v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`MutationsInterpreter::execute` installs a `CheckSortedTransform` at the
end of the pipeline to assert that the mutated stream is monotonic by the
storage's sorting key. The gate was `source.getMergeTreeData()`, which
matched any `MergeTree` source — including lightweight updates that route
through `storage->read(...)`.

When `max_threads > 1` that read dispatches to `readFromPool`, which hands
mark ranges to streams dynamically: each pipeline port sees a
non-monotonic sequence of granules and thus a non-monotonic sequence of
sort-key values, tripping `LOGICAL_ERROR: Sort order of blocks violated
...`. v1 lightweight updates didn't surface this because their mutation
pipeline only reads virtual columns (`_part_offset`, `_block_number`,
`_block_offset`, `_data_version`), so the sort-key column stayed out of
the header and `getStorageSortDescriptionIfPossible` returned empty.
v2 reads the physical source columns of the sort-key expression so they
can be persisted on every patch row, which put the sort-key column back
into the header and armed the check.

The check was also semantically unnecessary for that path: the
patch-part writer sorts its own output by `(sort_key..., _block_number,
_block_offset)`. Tighten the gate to `source.isMutatingDataPart()` so
it only fires for the single-part `MutateTask` flow — where inputs come
from `MergeTreeSequentialSource` and each stream is genuinely
sort-key-monotonic — and skip for lightweight updates and for other
storages (e.g. `Iceberg`) that expose a sorting key but read multiple
sorted files in parallel.

Regression test `04103_lightweight_update_v2_parallel_read_sort_check`
reproduces the failing `DELETE ... WHERE (a % 2) = 0` pattern from the
report with a `MergeTree` table small enough for CI but still wide
enough (500K rows, 1024-row granularity, `max_threads = 8`) to force
`readFromPool` to split ranges across ports.
`MergeTreePatchReaderMergeOnKey::readPatches` kept reading and appending
patch blocks until `needNewPatch` saw the last block's max sort-key catch
up to the current main block's max. In the common case where the caller's
deque was already drained — `MergeTreeReadersChain` evicted everything on
the previous main-cursor advance — the loop started from a null
`last_read_patch` and walked *every* range whose max was still below
`main_max`, pushing each into `results`. Every such block was already
useless for the current iteration (its max sort-key lies below
`main_min`, so `needOldPatch` would return `false` and the deque would
evict it on the very next call), but during this single `readPatches`
call the full catch-up stack lived in memory at once.

For a 5 M-row patch with 8-granule task ranges that is ~200 MiB of
pinned patch data per patch per worker thread; the reporter hit 60 GiB
peak on 96 patch parts covering 200 M rows, and a repro with 5 patches
over 10 M rows / `max_threads = 8` shows 1.65 GiB before the fix.

Inline the same predicate that `MergeTreeReadersChain::readPatches`
applies on the next call: after reading each block ask `needOldPatch`
whether it would be retained. If not — discard it. We still need the
block to anchor the `last_read_patch` pointer fed to `needNewPatch` on
the next loop iteration, so keep the most recent discarded block alive
in a local holder that outlives only this function call. Once the loop
finds a block whose max sort-key reaches the current main range, that
block is retained and the loop exits via `needNewPatch` as before.

Memory after the fix (same `SELECT count()` on the 10 M / 5-patch
repro):

  max_threads = 1   258 MiB  →  8.5 MiB
  max_threads = 4   1017 MiB →   31 MiB
  max_threads = 8   1.65 GiB →   63 MiB

Regression test `04104_lightweight_update_v2_eager_patch_discard` drives
the catch-up shape (5 disjoint `INSERT`s, one DELETE, `SELECT count()`
under a 64 MiB `max_memory_usage` cap) — the pre-fix code blows through
the cap; the fixed path stays well under it.
Perf top on a 10 M-row / 5-patch `SELECT count()` showed ~58 % of CPU
burned inside `std::unordered_map` machinery powering `Block::getByName`:

    45.17 % std::__hash_const_iterator<...>
     8.99 % DB::Block::findPositionByName(std::string_view)
     8.52 % std::__murmur2_or_cityhash
     6.83 % compareSortKeyRows
     4.33 % DB::Block::getPositionByName(...)
     4.10 % DB::Block::getByName(...)

`compareSortKeyRows` called `a_block.getByName(name)` and
`b_block.getByName(name)` **per column, per row comparison** — one full
`unordered_map` lookup for each sort-key value the merge touched. For an
`ORDER BY a` table the two-pointer merge issues one comparator call per
advanced cursor, so the hash-table lookups dominate the whole loop.

Resolve the sort-key column pointers once per block (`SortKeyColumns`),
then compare through raw `IColumn *` in the inner loop. The comparator
function is now an `ALWAYS_INLINE` helper over the pre-resolved cursor.

Throughput on the 10 M-row / 5-patch repro, `SELECT count()`:

                 before fix   after fix
  max_threads=1   500 K r/s   8.1 M r/s    (16×)
  max_threads=8                16  M r/s

Correctness-preserving: the comparator semantics (NULL-aware,
nan-last, per-column `reverse_flags`) are unchanged — only the lookup
scheme changed. The three existing v2 regression tests
(`04102_smoke`, `04103_parallel_read_sort_check`,
`04104_eager_patch_discard`) continue to pass.
`getRangesInPatchPartMergeOnKey` previously returned every patch mark
range of the v2 patch, relying on the streaming `needOldPatch` /
`needNewPatch` comparator inside the reader loop to skip useless ones
at read time. That works for memory (blocks are evicted before they
stack up) but is catastrophic for I/O and CPU: each read task handed
the whole patch must still *decompress and deserialize* every mark
range before deciding it's below main's cursor.

On a 200 M-row `SELECT count()` with a 100 M-row patch and
`max_threads = 8` this produced:

  PatchesReadRows                 50_432_368_896   ~500× the patch
  PatchesReadUncompressedBytes    1.47 TiB         against a ~1 GiB table
  Query duration                  49 s             at ~4 M rows/s

The amplification is `tasks_per_thread × #patch_ranges` because each
read task has its own `MergeTreePatchReaders`, and each of them needs
to walk the whole patch to cover the task's main range.

Fix at range-enumeration time. Both main and patch carry the sort-key
expression's result columns as column 0 of the primary index. Fold the
task's main `MarkRanges` down to `[min_granule, max_granule_end)`,
binary-search the patch's primary index for the granule window whose
sort-key spans `[main_sk[min_granule], main_sk[max_granule_end])`, and
return only that window. When main reads all the way to the end of the
part (`max_granule_end == main_marks_count`) there is no index row that
bounds main's max, so the upper bound is left open — every remaining
patch granule might still match.

Falls through to "all ranges" in edge cases where we can't determine a
bound (empty index, mismatched column types). Correctness is preserved;
only the perf benefit is lost.

Results on the 10 M-row / 8-patch `SELECT count()` repro, `max_threads = 1`:

  PatchesReadRows                 14_464_400  →  5_152_576  (~2.8×)
  PatchesReadUncompressedBytes    430 MiB     →  157 MiB    (~2.7×)
  Query duration                  1247 ms     →  1004 ms

And at `max_threads = 8`:

  PatchesReadRows                  89_271_826  →  8_104_768  (~11×)
  PatchesReadUncompressedBytes     2.66 GiB    →  247 MiB    (~11×)
  Query duration                   933 ms      →  176 ms     (~5×)

On the reporter's 200 M-row / 1-patch shape at `max_threads = 8` the
per-task read set is bounded by the task's main-range intersection
with the patch's sort-key range, eliminating the `tasks × patch-size`
multiplication that was responsible for the 50 B-row / 1.5 TiB read.

All existing v2 regression tests (`04102`, `04103`, `04104`) still
pass; the change is a pure read-set tightening and does not alter
patch-apply semantics.
After the per-task sort-key pruning in `getRangesInPatchPartMergeOnKey`,
`RangesInPatchParts::getRanges` still piped the result through
`getIntersectingRanges`, which snapped the tight range up to the
pre-computed 8-mark chunks in `ranges_by_name`. The snapping existed
for v1 Merge/Join, where the raw ranges come from a `_part_offset`
interval intersection that itself has no chunking. For v2 MergeOnKey
the raw ranges are already *granule-tight* — returning a 1-mark range
means the reader only needs 1 mark (~8192 rows), but
`getIntersectingRanges` turned that into an 8-mark chunk (~65k rows).

In a repro at 30 M rows × 15 patches with `max_threads = 8`:

  before: PatchesReadRows = 180 M  (~12× the total 15 M patch rows)
  after:  PatchesReadRows =  32 M  (~2.1×)
  peak memory 570 MiB → 148 MiB, query 1.9 s → 0.7 s

For MergeOnKey we now re-split the sort-key-tight range into chunks of
`max_granules_in_range` directly, so the reader still gets chunk-sized
read units but never reaches into marks outside the actual overlap.

Merge/Join paths are unchanged — they still consult
`getIntersectingRanges` because their raw ranges are not pre-trimmed
to granule boundaries.
Drop the serialized sort-key expression list + DESC flags from
`SourcePartsSetForPatch` (and the SQL-to-AST parser in `PatchPartsUtils`);
readers now rebuild the v2 patch's `KeyDescription` from the target table's
current in-memory metadata snapshot in `getPatchPartMetadataV2` and
`getAlterConversionsForPart`. The sinks carry a single `is_v2_format`
boolean instead of the SQL text and reverse-flag vector. Net -120 lines
of plumbing with no change in apply behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v2 patch part schema drops `_part_offset` from stored columns (it
was v1's sort-key tie-breaker and is dead weight under v2's two-cursor
merge). But `MutationsInterpreter` unconditionally emits `_part_offset`
in the pipeline block — it's one of the mandatory patch system columns
injected alongside `_block_number` / `_block_offset` / `_part` — so the
pipeline output is always one column wider than the sink's header.

The `SinkToStorage` wiring refuses to connect mismatched headers and
bails with `Block structure mismatch in function connect between
AddDeduplicationInfoTransform and MergeTreeSinkPatch stream: different
number of columns`. The write writer downstream (`writeTempPartImpl`)
already filters incoming block columns against the metadata
(`getAllPhysical().filter(block.getNames())`), so the solution is to
make the *pipeline header* match the sink by projecting `_part_offset`
out before `AddDeduplicationInfoTransform`.

Done via an `ExpressionTransform` driven by a simple-identity
`ActionsDAG` with `_part_offset` removed from its outputs, gated on
`isV2LightweightUpdateUsable` and a `pipeline_header.has("_part_offset")`
guard so v1 paths (which do keep `_part_offset` on disk) are left
alone.

Verified on a fresh `DELETE FROM kek WHERE a % 2 = 0` at 5 M rows:
`columns.txt` now lists 6 columns (`a`, `_part`, `_part_data_version`,
`_row_exists`, `_block_number`, `_block_offset`) — `_part_offset` is
gone and no `_part_offset.bin` is written. `SELECT count()` over a 30 M
row + OPTIMIZE FINAL setup stays at ~2× read amplification and 139 MiB
peak memory; existing v2 regression tests (`04102`, `04103`, `04104`)
still pass.
`MergeTreeReadersChain::addPatchVirtuals(ReadResult&, const Block&)`
caches the patch-relevant columns from the first reader's result block
into `result.columns_for_patches`, so later read steps can fold them
back in at apply time. It was reading only from
`header.cloneWithColumns(result.columns)`, where `header` is the
first reader's post-prewhere `result_sample_block`.

When the query filters on a column used by the v2 `MergeOnKey` patch's
sort-key expression (`SELECT str_10p FROM t WHERE id = 500000`),
prewhere's ActionsDAG evaluates the filter and then projects the
sort-key-source column (`id`) out — it isn't referenced by any
downstream read step and doesn't survive the projection. The dropped
column is stashed in `result.additional_columns` by
`executePrewhereActionsAndFilterColumns`, but `addPatchVirtuals` never
looked there, so `result.columns_for_patches` ended up without `id`.
`applyPatchMergeOnKey` then hit `Not found column id in block` when
resolving the sort-key columns via `SortKeyColumns::resolve` on the
second reader's block.

Fix: before the inner `addPatchVirtuals` call, fold any entries in
`result.additional_columns` that are missing from `result_block` into
it. Row counts stay aligned because `result.additional_columns` is
filtered/shrunk in lockstep with `result.columns` (see `applyFilter`
and `shrink` in `MergeTreeRangeReader`).

Reproduced on a `MergeTree` table `ORDER BY id` with patch columns
updated via lightweight UPDATEs and `apply_patches_on_merge = 0`:

    SELECT str_10p FROM test_lwu_join WHERE id = 500000;

previously raised `Code: 10 NOT_FOUND_COLUMN_IN_BLOCK`; now returns
the patched value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Write the semantic sort-key prefix length into source_parts.dat alongside
the format-version byte, so v2 patches no longer rebuild sort-key shape
from the current target-table metadata at every open. Slice the rebuilt
KeyDescription to the persisted length; this drops the n_full - n_appended
arithmetic from MergeTreeData::getAlterConversionsForPart and keeps
pre-ALTER patches' sort-key shape stable under schema drift (partition-id
hash isolates them regardless).

Move the v2 sort-key view (source/result column names, reverse flags,
expression) off PatchPartInfoBase's denormalized fields into a single
PatchSortKey struct populated once at construction from the patch part's
rebuilt metadata. Consumers (MergeTreePatchReaderMergeOnKey,
applyPatchMergeOnKey, getVirtualsRequiredForPatch,
MergeTreeReadersChain::addPatchVirtuals) read straight off
PatchPartInfo::sort_key.

Load source_parts.dat before primary.cidx in
IMergeTreeDataPart::loadColumnsChecksumsIndexes — the patch's metadata
rebuild now depends on getSortKeyPrefixSize(), and without the correct
prefix the index file read sees a too-short column count and throws
EXPECTED_END_OF_FILE on DETACH/ATTACH or server restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getRangesInPatchPartMergeOnKey used a non-strict patch_sk[j] >= main_sk[end]
upper-bound compare, but MergeTreeDataPartWriterOnDisk writes the primary
index's final mark as the last row's value (not a past-the-end sentinel).
For a main part with a single row, main_sk[final_mark] == main_sk[0], and
patches whose first key equalled that value got excluded even though main
has a matching row. Switch to strict `>` via is_le so equal values are
kept; at worst we over-read one granule and the apply loop filters per row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 patch parts hash a different set of inputs (sort-key AST text + v2
marker, not just column names), so their partition-id hashes differ from
v1's. These tests were written with v1 defaults and hardcoded v1 hashes;
update references and any hardcoded OPTIMIZE ... PARTITION ID 'patch-<hash>'
calls to the v2 hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@CurtizJ
CurtizJ marked this pull request as draft April 20, 2026 17:45
@clickhouse-gh

clickhouse-gh Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [de1a9de]

Summary:

job_name test_name status info comment
AST fuzzer (amd_debug, targeted) FAIL
Logical error: Context has expired (STID: 1805-460a) FAIL cidb IGNORED

AI Review

Summary

This PR moves lightweight UPDATE patch parts to the new v2 MergeOnKey format, keeps legacy v1 patches readable, and updates tests/docs around rolling upgrades and mixed patch versions. The main design looks reasonable, but I do not think the current head is merge-ready yet: one real MergeOnKey correctness bug is still present in the code that was marked resolved, and two previously-dismissed schema-evolution cases still look capable of misapplying or mis-merging patches.

Findings

❌ Blockers

  • [src/Storages/MergeTree/MergeTreeReadersChain.cpp:658-666, src/Storages/MergeTree/PatchParts/MergeTreePatchReader.cpp:283-291] MergeOnKey still reuses visible KeyDescription::column_names as real block-column names. For a valid table like CREATE TABLE t(id UInt64, `intHash32(id)` String, v UInt64) ... ORDER BY intHash32(id), the main side skips key materialization because main_block already has "intHash32(id)", and the patch side executes the key expression into that same visible name. Row matching then runs on user data instead of the computed hash, and patch application can overwrite the real updated column with hash values. Suggested fix: keep synthetic key results in collision-free internal names, or build a dedicated compare-only block on both sides instead of writing into user-visible column names.
  • [dismissed by author -- https://github.com/ClickHouse/ClickHouse/pull/103182#discussion_r3666916495] [src/Storages/MergeTree/MergeTreeBlockReadUtils.cpp:457-462] MergeOnKey still appends required key columns after the only recursive injectRequiredColumns pass. The supported ALTER ADD COLUMN b, MODIFY ORDER BY (a, b) followed by ALTER MODIFY COLUMN b UInt64 DEFAULT d + 1 shape from 04626_lwu_sort_key_column_with_default.sql means old parts can need a non-key d to materialize b, but this code never injects d, so evaluateMissingDefaults can synthesize it from the type default and compare/apply patches on the wrong (a, b) key.
  • [dismissed by author -- https://github.com/ClickHouse/ClickHouse/pull/103182#discussion_r3685019037] [src/Storages/MergeTree/PatchParts/PatchPartsUtils.cpp:115-121] v1 patch partitions still hash only physical column names. After a metadata-only type change, pre- and post-ALTER v1 patches for the same original partition can still land in one patch partition, and patch-part merges still pick future_part->parts.front()->getMetadataSnapshot() as the merge schema (src/Storages/MergeTree/MergeTreeDataMergerMutator.cpp:452-456). That leaves mixed-schema v1 merges reading or materializing later patches through the wrong schema.

💡 Nits

  • [docs/reference/statements/update.mdx:88] The user docs and the patch_parts_version setting description still say v2 stores the table’s “sorting key columns”, but expression keys now store the physical input columns needed to materialize the key. Please reword both so the storage/overhead contract matches the implementation for supported expression-key tables.
Tests
  • ⚠️ Add a stateless test for a quoted user column whose name matches an expression-key result (for example `intHash32(id)` with ORDER BY intHash32(id)) and a lightweight UPDATE to that column.
  • ⚠️ Extend 04626_lwu_sort_key_column_with_default.sql so the defaulted key column depends on a non-key stored column, proving that required MergeOnKey key columns get their recursive default-expression dependencies read from the main part.
  • ⚠️ Add a v1 compatibility test that leaves an old patch active, performs a metadata-only type change, writes another v1 patch in the same partition, and then merges the patch parts.
Final Verdict

Changes requested.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.70% 86.70% +0.00%
Functions 92.00% 92.00% +0.00%
Branches 79.20% 79.10% -0.10%

Changed lines: Changed C/C++ lines covered: 1457/1528 (95.35%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-improvement Pull request with some product improvements label Apr 20, 2026
@CurtizJ CurtizJ changed the title Lighteweight Update v2 Lightweight Updates v2 Apr 20, 2026
Comment thread src/Storages/MergeTree/MergeTreeSettings.cpp Outdated
Comment thread src/Storages/MergeTree/PatchParts/PatchPartInfo.h Outdated
Comment thread src/Storages/MergeTree/PatchParts/PatchPartInfo.h Outdated
Comment thread src/Storages/MergeTree/PatchParts/SourcePartsSetForPatch.cpp Outdated
Comment thread src/Storages/MergeTree/PatchParts/RangesInPatchParts.cpp Outdated
@CurtizJ
CurtizJ added this pull request to the merge queue Aug 17, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 17, 2026
@CurtizJ
CurtizJ added this pull request to the merge queue Aug 17, 2026
Merged via the queue into ClickHouse:master with commit 70a0c0f Aug 17, 2026
179 of 181 checks passed
@CurtizJ
CurtizJ deleted the patch-parts-sort-key-v2 branch August 17, 2026 21:58
@robot-clickhouse-ci-2 robot-clickhouse-ci-2 added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 17, 2026
@alexey-milovidov

Copy link
Copy Markdown
Member

AST fuzzer (amd_debug, targeted) FAIL
Logical error: Context has expired (STID: 1805-460a) FAIL cidb IGNORED

Oh, shit... Reverting.

@alexey-milovidov

Copy link
Copy Markdown
Member

This has been reverted in #116005, because it makes any lightweight UPDATE of a table whose sorting key has a direction throw a LOGICAL_ERROR:

Logical error: The size of reverse_flags (A) does not match the size of KeyDescription B  (STID: 3072-3c97)

InterpreterUpdateQuery::execute -> MergeTreeData::updateLightweightImpl -> getPatchPartMetadataV2 -> KeyDescription::getKeyFromAST.

Since the first occurrence on 2026-08-21 18:02 it has been reported against 29 distinct pull requests, across BuzzHouse, AST fuzzer and Stress test, on both architectures and in several sanitizer builds. An example report: Stress test (arm_debug), on an unrelated Web UI pull request.

Cause

getPatchPartMetadataV2 builds the patch part's sorting key by cloning the table's original sorting-key AST children and then appending two bare identifiers, in src/Storages/MergeTree/PatchParts/PatchPartsUtils.cpp:

for (const auto & child : sorting_key_expr_list->children)
    order_by_expression->arguments->children.push_back(child->clone());

order_by_expression->arguments->children.push_back(make_intrusive<ASTIdentifier>(BlockNumberColumn::name));
order_by_expression->arguments->children.push_back(make_intrusive<ASTIdentifier>(BlockOffsetColumn::name));

part_metadata.sorting_key = KeyDescription::getKeyFromAST(
    order_by_expression, patch_part_desc, /*virtuals=*/ {}, local_context,
    /*additional_columns=*/ {{BlockNumberColumn::name, ...}, {BlockOffsetColumn::name, ...}});

_block_number and _block_offset are supplied twice: once inline in the tuple and once as additional_columns. Only the second route maintains reverse_flags. In buildKeyColumns (src/Storages/KeyDescription.cpp):

  • the N cloned children are ASTStorageOrderByElements, so they contribute N entries to both reverse_flags and the expression list;
  • the two appended identifiers are not order-by elements, so they contribute 2 expression-list entries and no reverse_flags entries;
  • the additional_columns loop, which is what appends the matching false flags, skips both names, because they are already in column_names from the tuple.

So reverse_flags.size() is N against an expression list of N + 2, which trips the consistency check in KeyDescription::getKeyFromAST.

An ascending sorting key yields empty reverse_flags and short-circuits that check's !reverse_flags.empty() guard, which is why this is intermittent and has so far only been found by fuzzers rather than by the tests here.

The check itself and direction-carrying key descriptions (#111059, merged 2026-07-24) both predate this pull request and were correct together for a month. Before this change the patch-part sorting key was built from bare ASTIdentifiers, so reverse_flags was always empty and the check was unreachable — confirmed by diffing 70a0c0ff637^1 against 70a0c0ff637.

Suggested fix for the re-land

Dropping the two inline push_back calls and letting additional_columns add _block_number and _block_offset looks sufficient: that path appends the two matching false flags when reverse_flags is non-empty, and adds nothing when it is empty, so both the directional and the ascending case come out consistent. Worth a regression test for a lightweight UPDATE on a table with ORDER BY ... DESC, and one with a mixed-direction key.

Sorry for the disruption — the revert is only to take the failure off master, and is not a judgement on the feature.

alexey-milovidov added a commit to niyue/ClickHouse that referenced this pull request Aug 23, 2026
…-sort-key-v2"

This reverts commit 70a0c0f, reversing
changes made to b92cc80.
alexey-milovidov added a commit that referenced this pull request Aug 23, 2026
`03100_lwu_deletes_3` failed the `Stateless tests (amd_asan_ubsan, flaky
check)` job with `Test runs too long (> 180s)` at 209s:

https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=116007&sha=7d8004e22603dec5906a82841217020bf312c7cc&name_0=PR&name_1=Stateless%20tests%20%28amd_asan_ubsan%2C%20flaky%20check%29
#116007

The test is a long-standing offender rather than anything new: it also
failed this gate five times in the original #103182, and its median in
that same job is unchanged by this pull request (44.5s there against
45.1s here), so the `reverse_flags` fix is not implicated. Its sorting
key is entirely ascending, which leaves `reverse_flags` empty and the
changed code path a no-op for it.

Each of the three inserts drops from 100000 rows over 10000 distinct
`id`s to 20000 rows over 2000, so every `id` still occurs exactly ten
times per partition and all the predicates scale by the same factor of
five. The shape of what is exercised is untouched: the same three
inserts, three `UPDATE`s and four `DELETE`s, the same overlapping
delete ranges, the same delete of rows a previous patch had updated,
and the same four patch parts merging into one. `sum(v1)` stays at
`42 * 10 * 5`; the row counts and patch row counts scale.

Measured against a local build with the exact randomized `MergeTree`
settings of the failed run - `merge_max_block_size = 19` above all,
which makes the two `OPTIMIZE FINAL` merges linear in row count - the
test goes from 1.58s to 0.91s, a 42% cut (min of five runs). Under
default settings the saving is only 12%, because the cost is otherwise
dominated by fixed per-statement replicated-part overhead; it is the
adversarial settings the flaky check randomizes into that make row
count matter.

Five was preferred to a larger factor because it retains two granules
per part at the granularity that run randomized (12441), where a factor
of ten would leave one, while capturing nearly all of the saving (42%
against 47%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tiandiwonder added a commit that referenced this pull request Aug 24, 2026
One conflict, in `AlterConversions.cpp`, from two master commits landing in the code this branch
rewrites:

- `36bb4f1b3d6` reverts #103182 (patch parts sort key). Resolved master's way: the
  `PatchParts/PatchPartsUtils.h` include goes, along with `stored_sorting_key_columns` and
  `PatchMode::MergeOnKey`, which no longer exist. `isPatchPartSystemColumn` is still used here and
  still resolves — master dropped the same include and keeps both call sites.
  The revert does not touch the MATERIALIZED staging this branch was rescoped around: `level_of_column`
  and `affected_materialized` are still on master, so the premise in the description stands.
- `02c205b57da` binds `_sample_factor` to 1 in on-fly mutation expressions, in the same function this
  branch reshaped. Both sides kept: `getMutationActions` still takes the `MutationChainForRead` built
  above it, and `bind_sample_factor` is declared next to it for the loop that already merged cleanly.
  The include sets are the union — `Columns/ColumnConst.h` and `DataTypes/DataTypesNumber.h` from
  master, `<queue>` from here.

Verified on the merge result: `04869`, `04926`, `05023`, `05024` and
`01845_add_testcase_for_arrayElement` pass, and both benchmarks are unchanged — flat at 0.11s for
0/10/50/100 MATERIALIZED columns when nothing is recomputed, 0.38 / 0.41 / 0.52 / 0.67s for 1/10/50/100
when one is. `05023` matters most here: it drives a lightweight `UPDATE` with `apply_patch_parts = 1`,
the area the revert touches, and is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
groeneai added a commit to groeneai/ClickHouse that referenced this pull request Aug 24, 2026
…-covering-part-fsync

Master reverted the patch-parts sort key work (36bb4f1, reverting
ClickHouse#103182), which removed the `patch_part_index` parameter of
`createEmptyPart` that the previous merge had threaded through. The
parameter is gone from the whole tree, so the resolution keeps only
`force_sync` and adopts master's restored one-line-per-two-parameters
formatting (265819f).

Resolved conflicts:
  src/Storages/MergeTree/MergeTreeData.h   - drop patch_part_index from the
    declaration and its comment, keep the defaulted force_sync last
  src/Storages/MergeTree/MergeTreeData.cpp - same for the definition
  src/Storages/StorageMergeTree.cpp        - createEmptyDataParts no longer
    forwards patch_part_index

The defect is unchanged on current master: createEmptyPart still gates the
part-directory guard on fsync_part_directory and the content fsync on
fsync_after_insert, both default off, and renameAndCommitEmptyParts still
syncs nothing. The parent-directory sync stays after commit(), i.e. after
master's PART_IS_TEMPORARILY_LOCKED retry loop exits, so it still runs
exactly once on the success path.
alexey-milovidov added a commit that referenced this pull request Aug 25, 2026
The merge had two merge bases (criss-cross history), so the default
three-way merge produced a bogus virtual base and 235 spurious conflicts
in files this pull request does not touch.

Resolution: for every conflicted file outside this pull request's own set
of changes, `master`'s version was taken verbatim (including deletions),
since the branch content for those files is identical to the newest real
merge base `ecc01ef26beb`. The three files that this pull request does
touch - `ReadFromMergeTree.h`, `StorageMerge.cpp` and
`04024_pr_read_in_order_through_join.sql` - were merged with
`git merge-file` against the real base; only `ReadFromMergeTree.h` had a
real (purely additive) conflict, where both sides were kept: this branch's
`prefer_multiple_streams` / `read_in_order_requested_by_plan_optimizer` /
`query_task_size_limit` accessors and master's new
`isPartitionIndependentProcessingProfitable`.

The same criss-cross artifact silently dropped
`src/Storages/MergeTree/PatchParts/SourcePartsSetForPatch.{h,cpp}`, which
`master` restored by reverting #103182; they were restored from `master`.

After the resolution, the net diff against `master` is identical, file by
file and line by line, to the branch's net diff before the merge.
valerypetrov pushed a commit to valerypetrov/ClickHouse that referenced this pull request Aug 28, 2026
The editing step of the `NightlyChangelog` job has been failing its own
verification every night since 2026-08-12, always the same way:

    Entries disappeared in the edit without a matching revert (['103182', '109946'])

`verify_edit` requires that every raw entry of a strict-retention category
keeps its pull request link - an entry may be rewritten, merged or moved, but
not dropped, because the generation point has already moved past it and no
later run will offer it again. The prompt never said so, and the skill stated
the rule only implicitly, so the agent pruned entries it judged
insignificant: `ClickHouse#103182` (`Lightweight Updates v2`, a `Backward Incompatible
Change`) was deleted outright.

The second half of the same failure is the revert handling. `ClickHouse#109946` fixed
the propagation of settings in `accurateCastOrDefault`, `ClickHouse#114911` reverted
it, and `ClickHouse#114912` reverted that revert - all three in one range. The skill
covers this in section 2.5, but the agent reached the "landed and then
reverted, so delete both" rule first and lost a fix that ships.

So:

- The prompt now lists the strict-retention categories (from
  `STRICT_RETENTION_CATEGORIES`, so the two cannot drift apart), says which
  entries may be deleted and why deleting anything else is unrecoverable, and
  states the revert-of-revert rule.
- The skill gets a retention rule ahead of the individual edits, and the
  walk over `NO CL ENTRY` checks whether a revert is itself reverted before
  it concludes that a change was reverted. The `accurateCastOrDefault` chain
  is written out there as the worked example.
- A retry now tells the agent why the previous attempt was rejected. Without
  it, all three attempts of a run reproduced the same edit and the same
  rejection - visible in every run from 2026-08-12 on.

A hand edit that followed these rules passed `verify_edit` on the first try,
which is how the 26.8 backlog (759 raw entries) was cleared in
ClickHouse#111720.

Related: ClickHouse#111720
Related: ClickHouse#116138
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-backward-incompatible Pull request with backwards incompatible changes pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants