Skip to content

Type the _partition_value virtual column so it can hold the values it exposes - #119394

Open
groeneai wants to merge 5 commits into
ClickHouse:masterfrom
groeneai:groeneai/type-partition-value-by-produced-key
Open

Type the _partition_value virtual column so it can hold the values it exposes#119394
groeneai wants to merge 5 commits into
ClickHouse:masterfrom
groeneai:groeneai/type-partition-value-by-produced-key

Conversation

@groeneai

@groeneai groeneai commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

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

Fixed the _partition_value virtual column for a PARTITION BY with modulo with an unsigned left and a signed right operand. Such a key stores the values moduloLegacy computes, whose signedness differs from the declared type, so a negative stored value was reported as a large positive one below 128 bits, and reading it threw Bad get: has Int128, requested UInt128 (BAD_GET) at 128/256-bit widths. The declared element type is now one that can hold the stored values, so for such a key it changes (e.g. Tuple(UInt32) to Tuple(Int32)) even where the value was already correct.

Description

_partition_value was typed from the declared PARTITION BY, but a part's values are produced and persisted by the adjusted key, where modulo becomes moduloLegacy for on-disk compatibility. ResultOfModulo takes signedness from its left operand alone, ResultOfModuloLegacy from either, so an unsigned left with a signed right operand makes declaration and data disagree.

A positive literal is unsigned, so this needs no CAST:

CREATE TABLE t (c0 Int32) ENGINE = MergeTree ORDER BY tuple() PARTITION BY (3000000000 % c0);
INSERT INTO t VALUES (-1);

SELECT _partition_value FROM t;                        -- (3000000000)  <- wrong
SELECT partition_id FROM system.parts WHERE table='t'; -- -1294967296

The part is -1294967296: the column contradicted its own part's name. Below 128 bits Field::safeGet treats Int64/UInt64 as interchangeable and the bits were reinterpreted; at 128/256 bits it threw BAD_GET. A predicate on the column reaches the same path, so this is not confined to queries that select it.

Fix. An element keeps its declared type when that type can represent every produced value (canBeSafelyCast), else takes the produced type. A signedness divergence never can; a widening can, so an ordinary PARTITION BY <signed> % <small> key is untouched. Both carriers that declare the column (the table's virtuals and projection metadata) are fixed; the three sites materializing it read the declared type. MergeTreePartition::getID is untouched, so partition IDs do not change, and no on-disk format or setting changes.

Validation. New test 04952, both directions per carrier with distinct build IDs, the wrong value checked against system.parts.partition_id as an independent oracle; Nullable, LowCardinality, nested-tuple and array wrapper keys; 50/50 randomized. The partition and projection subsets moved no reference, and each failure there reproduces on a base binary or carries a documented sandbox signature.

Third layer of this mismatch: #116606 fixed the parse side, #119333 the render.


Workflow [PR]
Sync PR [sync-upstream/pr/119394]

groeneai and others added 3 commits September 10, 2026 23:51
… exposes

The values a part stores in MergeTreePartition::value are produced and
persisted by the ADJUSTED partition key, in which `modulo` is rewritten to
`moduloLegacy` for on-disk compatibility (MergeTreePartition::adjustPartitionKey).
_partition_value's declared type was derived from the DECLARED key instead.

The two keys disagree because the result traits differ: ResultOfModulo takes
signedness from the left operand alone and widens only when that operand is
signed, while ResultOfModuloLegacy takes signedness from either operand and
never widens. So exactly one divergence exists, an unsigned left operand with a
signed right one, and it has two faces. Below 128 bits Field::safeGet treats
Int64 and UInt64 as interchangeable, so ColumnVector<UInt32>::insert accepts a
Field holding an Int32 and reinterprets the bits: a negative produced value
surfaces as a large positive one. At 128/256 bits there is no such leniency and
the discriminant mismatch throws BAD_GET.

An element now keeps its declared type when that type can represent every
produced value (canBeSafelyCast), and takes the produced type otherwise. A
signedness divergence is never representable; a pure widening is, so an
ordinary `PARTITION BY <signed> % <small>` key keeps the type it announces
today. Typing the whole tuple by the produced key was rejected: it would move
01848_partition_value_column's declared element from Int16 to Int8 for no
defect.

Two carriers declare the column and both are fixed: the MergeTreeData
constructor and fillProjectionDescriptionByQuery. The three sites that
materialize it read the declared type, so they need no change.
MergeTreePartition::getID is deliberately untouched, because it would change
partition IDs.

adjustPartitionKey gained an overload taking the key, columns and virtuals
directly, since the MergeTreeData constructor has no metadata snapshot yet, its
virtuals being what is under construction. Passing createVirtuals(nullptr) as
the virtuals for that analysis mirrors registerStorageMergeTree, which analyses
the declared PARTITION BY against exactly that set, and cannot lose anything: a
key naming this column is rejected today with UNKNOWN_IDENTIFIER, so no
existing key can reference the column being declared.

Third and last layer of this mismatch. The parse side was fixed in ClickHouse#116606 and
the render side is ClickHouse#119333.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
04952 declared a single-element partition key in every scenario, so the
per-element rule was indistinguishable from an all-or-nothing one: keeping the
whole declared tuple only when every element is representable, and otherwise
replacing the whole tuple, passes all eight of its assertions. A mixed key now
pins the difference. Its first element diverges (declared UInt128, produced
Int128) and takes the produced type, while its second merely widens (declared
Int16, produced Int8) and keeps its declared one, so the expected type is
Tuple(Int128, Int16); an all-or-nothing rule gives Tuple(Int128, Int8) and
changes only this line.

PartitionValueColumn::type now bounds its loop by the smaller of the two
sizes. The chassert above it is debug-only, so indexing the produced types by
the declared count was unguarded in a release build. Not reachable today,
because moduloToModuloLegacyRecursive renames ASTFunction::name and preserves
arity, so this only stops a future rewrite that does not.

Nothing here widens what the produced-key analysis accepts, and it does not
need to: moduloLegacy accepts strictly fewer operand pairs than modulo, since
IsOperation<ModuloLegacyImpl>::modulo is false and both division and
allow_decimal key off it, but registerStorageMergeTree already runs that same
legacy analysis on every table registration when it builds the implicit
minmax-count projection. A key whose legacy form cannot be analysed, such as a
Date left operand, therefore already fails to load one layer earlier with the
same ILLEGAL_TYPE_OF_ARGUMENT; deriving the produced key in the MergeTreeData
constructor adds no way for a table to stop loading. Measured across 22
operand shapes, including Date, Date32, DateTime, DateTime64 and Decimal left
operands and an explicit projection over such a key: accepted and rejected
sets are identical with and without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Tuple and Array

The whole minimality guarantee of this change rests on canBeSafelyCast, which
recurses through Nullable and LowCardinality on both source and target and into
Tuple and Array element types. Every partition key 04952 declared was a plain
numeric type, so a wrapper-specific degradation inside that recursion would
have mistyped every wrapped key while reddening nothing.

Five keys now pin it. A Nullable divergent key is re-typed inside the wrapper
(Tuple(Nullable(UInt32)) before, Tuple(Nullable(Int32)) after) and a Nullable
widening key keeps its declared type inside it, which is the pair that shows
the choice is made under the wrapper rather than on it; LowCardinality behaves
the same way. The Array key covers the element-container arm.

Reaching the Tuple recursion needs a doubled tuple(): a top-level tuple() IS
the key list, so PARTITION BY tuple(a % b, c) is two top-level elements and
exercises only the per-element loop, while
PARTITION BY tuple(tuple(a % b, c)) leaves the inner tuple as one element's own
type. That key declares Tuple(Tuple(UInt32, Int32)) before this change and
Tuple(Tuple(Int32, Int32)) after.

Four of the five are therefore red on an unfixed binary. The fifth, the
Nullable widening key, is deliberately unchanged by this fix and guards the
opposite direction: replacing every element with its produced type moves it to
Tuple(Nullable(Int8)), and moves only the two other widening lines with it.

mod_negative also asserts its type now. It is the file's most extreme
divergence, a declared UInt128 against a produced Int128 holding -1, and the
value alone would survive a rule that picked any other signed type at least
128 bits wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@groeneai groeneai added can be tested Allows running workflows for external contributors groeneai-origin-follow-up PR origin: groeneai's own out-of-scope finding during its PR work labels Sep 11, 2026
@groeneai

Copy link
Copy Markdown
Collaborator Author
Internal second-model review

Two independent passes over the final tree (a cold read, then a second model). Two items are worth
surfacing; the rest were clean.

⚠️ LowCardinality(Nullable(...)) is not in the test's wrapper matrix. Mutating
canBeSafelyCast's LowCardinality branch to strip target nullability would reject a safe
LowCardinality(Nullable(Int8)) to LowCardinality(Nullable(Int16)) widening, and no assertion in
04952 would move. Declined for stated reasons rather than on size: the declared and produced keys
always have identical wrapper structure (both analyse the same PARTITION BY; only modulo's result
trait differs), so such a mutation can only cost minimality, never soundness, and the two recursion
arms it composes are each pinned by a row that is red on an unfixed binary (lowcardinality, plus
nullable and nullable widening for the two directions of the Nullable case). DataTypes/Utils.cpp
is not touched here. Glad to add the row if you would rather see it asserted.

💡 Nested tuples are replaced whole, not per member. The choice is made once per top-level key
element, so when any member of a nested tuple diverges, canBeSafelyCast rejects the whole tuple and
the entire produced tuple type is taken: PARTITION BY tuple(tuple(<unsigned> % <signed>, c1 % 100))
yields Tuple(Tuple(Int32, Int8)), not Tuple(Tuple(Int32, Int16)). 04952's comment on that
scenario reads as if the safe sibling kept its declared type; the commit message has it right, and the
comment gets corrected on the next push here.

@clickhouse-gh clickhouse-gh Bot closed this Sep 11, 2026
@clickhouse-gh clickhouse-gh Bot reopened this Sep 11, 2026
@clickhouse-gh

clickhouse-gh Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [047bf17]

Summary:

job_name test_name status info comment
Stateless tests (arm_binary, parallel) FAIL
03100_lwu_deletes_4_index FAIL cidb
Stress test (arm_tsan) FAIL
Logical error: Shard number is greater than shard count: shard_num=A shard_count=B cluster=C (STID: 5066-3bb2) FAIL cidb

AI Review

Summary

This PR retunes _partition_value so it uses the adjusted partition-key result type whenever the declared PARTITION BY type cannot represent the values actually stored in parts, and it threads that through the normal projection metadata path as well. I reviewed the current diff, the existing discussion, and the affected MergeTree/projection call sites in the current tree; with the all-green CI run on commit 047bf175b1ea412b623d971a4e016c90daf90106, I did not find any remaining correctness, compatibility, or coverage issues in the patched surface.

Final Verdict

✅ No blocking or major issues found.

LLVM Coverage Report

Measured on commit 047bf17.

Metric Baseline Current Δ
Lines 89.00% 88.90% -0.10%
Functions 91.80% 91.80% +0.00%
Branches 81.30% 81.30% +0.00%

Changed lines: Changed C/C++ lines covered: 39/39 (100.00%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Sep 11, 2026
04952 creates tables whose partition key diverges at 128 bits, which is the
defect it asserts. Inserting a part into one of them also makes its part_log
entry render the partition with the declared key (PartLog::addNewPartsImpl ->
MergeTreePartition::serializeToString(metadata)), which throws BAD_GET and logs
it at ERROR. clickhouse-test runs the client with --send_logs_level=warning and
fails any test whose stderr is not empty, so Fast test reported "having
stderror" for all four of those inserts while every assertion matched.

Rendering with the produced key is the render layer's fix, ClickHouse#119333, whose own
test covers the part_log carrier, so this does not widen the diff into it.
send_logs_level = 'fatal' is what the suite already uses for expected
server-side log noise; it keeps <Fatal> visible, and CI's server-log scan looks
for <Fatal>, not <Error>.

Measured: at the runner's own level the test's stderr is empty; with the client
raised to trace, forwarded traffic drops from 90209 bytes to the SET statement's
own <Debug> line, which is itself below the runner's level. Assertions and the
reference file are unchanged, 50/50 randomized.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@clickhouse-gh clickhouse-gh Bot added the comp-mergetree MergeTree* family: parts, merges, primary index, column statistics, background data transformation. label Sep 11, 2026
PartitionValueColumn::type makes one choice per top-level key element, and
canBeSafelyCast rejects a whole tuple as soon as one of its members is not
representable, so a divergent member takes the entire element to the produced
type rather than only itself. The comment claimed the sibling member was left
alone, which is not what happens: with the sibling changed to a merely widening
expression, the key reports Tuple(Tuple(Int32, Int8)) rather than
Tuple(Tuple(Int32, Int16)), while the same two expressions as separate
top-level elements report Tuple(Int32, Int16).

Comment only. mod_nested's sibling is Int32 in both keys, so the shipped
assertion is unchanged; the per-element choice at the top level is already
pinned by mod_mixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

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

✅ No significant changes.

Binary sizes

programs/clickhouse-stripped: smaller than the master baseline by the known offset between the two builds, so the difference is not shown. A delta that differs from the offset by more than 50% of it is shown, in either direction.

The official master build is compiled with -g and a pull request build is not, and XRay counts debug instructions towards its instrumentation threshold, so master instruments thousands of functions more and its binary is ~0.4% larger no matter what the pull request does.

Compile time of recompiled translation units

297 translation units recompiled, 2755 s compile time in total, 297 of them have a recent master baseline.

Job report

@groeneai

Copy link
Copy Markdown
Collaborator Author

CI finish ledger - 047bf17

Every failure below has an owner: a fixing PR (mine or external), or a full-effort fix task whose
fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

Check / test Reason Owner / fixing PR
Stateless tests (arm_binary, parallel) / 03100_lwu_deletes_4_index trunk regression; the runner's own minimizer reproduced it 125/125 under the randomized pair min_bytes_for_wide_part=0 + patch_parts_version=v1 and 0/n without randomization #119400 (mine, open)
Stress test (arm_tsan) / Logical error: Shard number is greater than shard count: shard_num=6 shard_count=1 (STID 5066-3bb2) trunk bug in the parallel-replicas shard scope; 7 unrelated pull requests over 30 days, 0 true-master #113589 (mine, open)

Session id: cron:our-pr-ci-monitor:20260911-120034

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

Labels

can be tested Allows running workflows for external contributors comp-mergetree MergeTree* family: parts, merges, primary index, column statistics, background data transformation. groeneai-origin-follow-up PR origin: groeneai's own out-of-scope finding during its PR work pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant