Skip to content

Fix JOIN with a Join-engine table whose key type is narrower than the left key - #112390

Open
groeneai wants to merge 13 commits into
ClickHouse:masterfrom
groeneai:fix-104918-storagejoin-lookup-through-interposed-step
Open

Fix JOIN with a Join-engine table whose key type is narrower than the left key#112390
groeneai wants to merge 13 commits into
ClickHouse:masterfrom
groeneai:fix-104918-storagejoin-lookup-through-interposed-step

Conversation

@groeneai

@groeneai groeneai commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes: #104918

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 INCOMPATIBLE_TYPE_OF_JOIN when joining against a table with the Join engine whose integer key type is narrower than the left table's key, for example a Join table keyed on UInt32 joined with a UInt64 column. The fix applies with the default enable_analyzer = 1. The planner used to widen the right key with a _CAST, which the Join engine cannot look up because its hash table is built at INSERT time on the declared key type. The left key is now converted down to the storage key type with accurateCastOrNull instead, so a left value outside the storage key domain matches nothing and the direct Join-engine lookup is preserved. Closes #104918.

Description

SELECT count() FROM big AS l ANY LEFT JOIN jt AS r USING (k), where jt is ENGINE = Join(ANY, LEFT, k) with k UInt32 and big.k is UInt64, failed: Code: 264 ... but column '_CAST(__table2.k, 'UInt64'_String)' was found. (INCOMPATIBLE_TYPE_OF_JOIN).

predicateOperandsToCommonType casts both keys to their common type. For a special storage it skips the right-side cast only when the right type already matches up to nullability, so a width mismatch still put a _CAST on the storage key, which chooseJoinAlgorithm's column_mapping lookup -- knowing only the storage's bare column names -- then refused.

Widening the right key cannot work: a StorageJoin's HashJoin is reused as built, so the stored key type cannot change at query time. The conversion moves to the left key instead, and it must be accurateCastOrNull rather than a plain CAST, because a plain CAST wraps out-of-range left keys and fabricates matches (against an ENGINE = Memory oracle on the same rows: 60 with this PR, 100 with a plain CAST).

accurateCastOrNull is a pure domain check only for canonical native integers, so the rewrite is guarded to those and every other shape keeps today's behaviour, asserted case by case in the test.

Covered: UInt64/UInt32 left keys under every Nullable/LowCardinality wrapper combination against UInt32 and Nullable(UInt32) storage keys, negative left values, the ON spelling, IS NOT DISTINCT FROM, SEMI, ALL RIGHT/ALL FULL USING, and multi-key storages. The test asserts FilledJoin is still in the plan for every fixed carrier, so the direct lookup is preserved rather than degraded to a generic hash join, which for a multi-key Join table is not even possible (UNSUPPORTED_JOIN_KEYS). No query that succeeds today changes its answer, and no setting is added or changed.

Declined shapes

Each keeps today's INCOMPATIBLE_TYPE_OF_JOIN and is pinned with a serverError or plan assertion, so it is a recorded decision, not an oversight.

  • The conversion would truncate instead of reject, fabricating matches: Decimal64(1) to Int32 turns 1.5 into 1, DateTime64(3) to DateTime drops sub-seconds.
  • The storage key is a custom-named integer such as Bool, where accurateCastOrNull(2, 'Bool') is true, not NULL.
  • The storage key is LowCardinality, which accurateCastOrNull cannot target. Such keys are already refused even for a type-matched left key: a separate pre-existing defect.
  • The mismatch is nullability only and the left key is the nullable side: the pre-existing right-side skip already leaves the storage key un-cast, so that shape works today. The mirror case (nullable storage key, non-nullable left key) does not, and is fixed. The one-sidedness is deliberate, pinned both ways.
  • The left key is a wide integer (Int128/UInt128/Int256/UInt256): whether accurateCastOrNull is a pure domain check there needs its own verification.
  • The right side is a key-value entity (dictionary, EmbeddedRocksDB, KeeperMap): no frozen hash table, and the cast is handled already.
  • The common type is wider than the left key, as when a signed and an unsigned integer of comparable width meet (UInt32 left against Int16 storage gives Int64), so the storage key would still be cast. Mixed signedness alone is not the boundary: Int64 left against UInt32 is fixed.

The reporter's observation that WHERE materialize(1) makes the query succeed points at filter push-down, not constant-foldability: an interposed FilterStep abandons the Join-engine path for a generic hash join, so that shape is not the join working. Fixing it alone would make that query start failing, so it is sequenced separately. The workaround ON toUInt32(l.k) = r.k is unsafe too: it returns 100, not 60.

With enable_analyzer = 0 the same query fails earlier and differently: the old analyzer's TableJoin::inferJoinKeyCommonType raises TYPE_MISMATCH, not INCOMPATIBLE_TYPE_OF_JOIN. This PR does not touch that path and its behaviour there is identical before and after, which is why both tests pin enable_analyzer = 1.


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

groeneai and others added 7 commits July 28, 2026 15:59
… left key

A join against an ENGINE = Join table whose declared key type is narrower than
the left table's key was refused with INCOMPATIBLE_TYPE_OF_JOIN, for example a
Join table keyed on UInt32 joined with a UInt64 column.

predicateOperandsToCommonType casts both keys to their least supertype. Its
storage-join carve-out suppresses the right-side cast only when the right type
already equals the common type up to nullability, so a width mismatch still
emitted a _CAST on the storage key. The column_mapping lookup in
chooseJoinAlgorithm only knows the storage's bare column names, so that key
missed and the join was rejected.

A StorageJoin's HashJoin is rebuilt from getRightSampleBlock() and reused as-is,
so the stored key type cannot change at query time and widening the right key is
not fixable at the guard. Convert the left key down to the storage key type with
accurateCastOrNull instead and leave the right key as the bare storage column,
which keeps the direct lookup and preserves the wide comparison: a left value
outside the storage key domain matches nothing.

accurateCastOrNull is only a pure domain check for canonical native integers, so
the rewrite declines where it would truncate (Decimal, date-like types), where a
custom-named alias collapses the domain (Bool, Nullable(Bool)), for
LowCardinality storage keys, for nullability-only mismatches that need no
conversion at all, and for key-value entities, which have no frozen hash table.
Each declining case is asserted in the new tests so it stays a decision rather
than an oversight.

Closes ClickHouse#104918.
…ess-safe

Fix round 1, items 2 to 6 of the review fix plan. No source change: every item
here is test coverage, a tag, or a contract wording correction.

Mixed signedness is pinned as a recorded residual. getLeastSupertype promotes a
signed and an unsigned operand to a type wider than both, so for a UInt32 left
key against an Int16 storage key the common type is Int64, the rewrite declines
and the refusal stands. Measured: UInt32/Int16 and Int64/UInt32 both give Int64,
Int32/UInt16 gives Int32, and UInt64/Int16 raises NO_COMMON_TYPE before the
branch. The changelog entry now states the same-signedness restriction instead of
promising the declined pair.

The LowCardinality-left by Nullable-storage-key quadrant was untested, and three
wrapper-matrix rows asserted results without asserting that the direct lookup
survived, so a silent degradation to a generic hash join would have passed. Both
are covered now, each against an ENGINE = Memory oracle and each with a
FilledJoin assertion.

Null-safe equals reaches the same conversion, and RIGHT and FULL USING joins
materialise the right key from the left key column whose type the rewrite
changes. Both are measured correct against an oracle and both keep the direct
lookup, so they are pinned rather than left to inference.

Both tests carry the memory-engine tag. With echo the statement text is the
output, so the stress job's --replace-log-memory-with-mergetree arm would rewrite
21 lines in one test and 3 in the other and diverge from the reference.
…in the remaining shapes

The first clause of canNarrowLeftKeyToStorageKey declines a nullability-only
mismatch in one direction only, and that is deliberate: when the LEFT key is the
nullable one the pre-existing right-side skip already leaves the storage key
un-cast, so that pair has a working plan today and rewriting it changed the plan
of every such existing join. A nullable STORAGE key is the opposite case, since
that skip does not fire for it, the storage key still gets a _CAST and is still
refused, so narrowing the left key is what makes the query work at all. The
clause was correct but undocumented and untested, which read as a defect. It is
now stated in the comment and pinned in both directions at once.

Three more shapes are pinned rather than changed, because all three behave the
same as they do on master:

- a wide integer left key (UInt128 against a UInt64 storage key) stays refused,
  because the narrowing is restricted to native integer widths;
- null-safe equals against a nullable storage key stays refused, because the
  null-safe rewrite wraps both keys in tuple() and a tuple expression is not a
  mapped storage column;
- the mirrored nullability pair is now covered by an oracle comparison and a
  FilledJoin assertion for both a plain and a LowCardinality left key.

Also renumbers the test sections so they ascend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ction

The stress job passes randomized settings to every stateless test as
--client-option, so they are in force for CREATE TABLE as well as for the
queries. Two of those settings break these tests.

join_use_nulls is set for one thread in three. registerStorageJoin seeds the
Join engine table's use_nulls from the session setting, and getJoinLocked then
refuses any join whose forceNullableRight disagrees with it, so 04652 aborted
on its very first query with INCOMPATIBLE_TYPE_OF_JOIN. That is worse than a
plain failure: the serverError pins further down assert the narrowing decline,
and under the injection they would have kept passing while actually observing
the settings-mismatch refusal. Independently, the setting makes an unmatched
LEFT join row read NULL instead of 0, which moves 24 reference lines in 04652
and 3 in 04653.

join_algorithm is overridden for odd threads to one of five algorithms.
04653 asserts that a type-matched key-value join still uses
DirectKeyValueJoin, but tryDirectJoin returns nothing unless the direct
algorithm is enabled, so the assertion silently flipped to 0 while the results
stayed correct. 04652 needs no such pin: a Join engine table is attached and
returned before any join_algorithm gate is consulted, in both
trySetStorageInTableJoin and chooseJoinAlgorithm.

Both pins are file-level SET statements because the CREATE TABLE statements
are where use_nulls is frozen into the table. Removing either one reddens the
matching test under the matching injection.

Also close two coverage gaps in 04652. The Join engine accepts SEMI and ANTI
as well, and a SEMI LEFT join reaches the same conversion, so it gets an
oracle-backed carrier and a plan assertion; master refuses that shape today.
And the projected USING key's declared type is now asserted rather than only
its values, since the rewrite changes the type of the column it is
materialised from.
…abase

The stress job runs some of its workers with a single shared database for
every test (stress.py appends --database=test_N). In that mode
clickhouse-test neither creates nor drops a per-test database, so the 51
tables these two tests created were left behind and a second run failed
with TABLE_ALREADY_EXISTS. Two of the names also collide with tables owned
by other tests: 00830_join_overwrite creates kv, as a Join-engine table,
and 03394_distributed_broadcast_join creates big.

Each DROP pins ignore_drop_queries_probability to 0, because the same job
injects 0.2 for every non-upgrade thread and InterpreterDropQuery skips a
DROP of a table that stores data on disk when that roll fires, reporting
success. Without the pin the prologue passes its first run while cleaning
up nothing.

Also assert that the narrowing rewrite is visible in the plan for a shape
where it does fire, so the three assertions that it does not fire have a
positive counterpart.
…disarmed by a setting

The three plan pins that assert the narrowing rewrite does NOT fire (nullable-left,
the RIGHT-join mirror, and the key-value entity) used EXPLAIN PLAN description = 1.
That spelling observes the join's action text only indirectly: InterpreterExplainQuery
forces query_plan_options.actions = true, but only when explain_query_plan_default is
PRETTY. Under explain_query_plan_default = 'legacy' the forcing is skipped, actions
falls back to its declared default false, and the pins report 0 because nothing is
printed at all rather than because the rewrite declined. They would have stayed green
even if the rewrite had started firing on any of those three paths.

The mode is not exotic: 489 stateless tests set it to legacy, so a server default, a
future default flip, or a client option would quietly disarm all three assertions.

actions = 1 sets the flag the plan printer actually reads, so it cannot be turned off
from outside the test. Measured on the positive control, where the rewrite does fire:
actions = 1 reports 1 under both PRETTY and legacy, while description = 1 reports 1
under PRETTY and 0 under legacy. The four pins keep their values (1, 0, 0, 0) and only
the three echoed statement lines move in the reference files.
…n pin setting-independent

The narrowing rewrite lives only in the analyzer's plan path: every JoinStepLogical
construction site is under src/Planner or Optimizations/optimizeJoin.cpp. The old
analyzer builds FilledJoinStep directly and types StorageJoin keys through
TableJoin::inferJoinKeyCommonType with allow_right = !isSpecialStorage(), which is
false for a Join engine, so it refuses this shape with TYPE_MISMATCH ("Can't change
type for right table"). Both new tests carried no analyzer pin, so they failed
deterministically wherever the old analyzer is selected: the dedicated old-analyzer
stateless job, and the stress job's compatibility randomization (the analyzer default
flipped in 24.3, so any drawn version below that turns it off). Pin it with a session
SET rather than a no-old-analyzer tag: the SET also defends against the compatibility
route, because applyCompatibilitySetting skips a setting the session already changed,
and it keeps the tests running in every lane instead of silently dropping them.

The DirectKeyValueJoin assertion in the key-value test read EXPLAIN PLAN with
description = 1. That string is the join algorithm name, reached only through
describeJoinActions from FilledJoinStep::describeActions, which runs under
options.actions; under the legacy explain default it is absent, so this positive pin
returned 0 and the test failed. Read it with actions = 1 instead, which is
setting-independent. The FilledJoin pins are the step name, printed unconditionally,
and are left as they are.

Also record why the narrowed left key is Nullable while the storage key is not: the
conversion is accurateCastOrNull, the resulting NULL marks a left value outside the
storage key domain and never matches, and the join tolerates the difference up to
typesEqualUpToNullability.

No executable change to src/. The Build ID moves because this is a Debug build and the
DWARF line tables shift by the inserted comment lines; the objects are byte-identical
after --strip-debug.
@groeneai

Copy link
Copy Markdown
Collaborator Author
Internal second-model review (click to expand)

Two independent review passes ran against this change before it was opened: a cold
code review that enumerated the invariant carriers without reading the implementation's
own notes, and a separate adversarial model pass. Findings were adjudicated against the
code; every accepted one was fixed by a further round.

Approach review (before implementation): 6 rounds, 7 findings, all accepted. Four
killed a design. The rejected designs and why they are wrong:

  • Admitting temporal narrowing (DateTime64(3) to DateTime): the conversion
    truncates the sub-second part instead of rejecting, so it reports a match where the
    wide comparison has none.
  • Guarding on a Bool-specific name check: Nullable(Bool) defeats it, and
    accurateCastOrNull(2, 'Nullable(Bool)') is true, so three rows match instead of one.
  • Testing nullability with a hand-composed unwrap instead of the shared helper: that
    silently declines the LowCardinality(Nullable(UInt64)) carrier.
  • Keying the new branch on the existing special-storage flag: that flag is also true for
    key-value entities, which have no frozen hash table and already handle the conversion.

Code review (after implementation): 7 rounds, 14 findings from the adversarial pass
plus 23 from the cold pass. Substantive ones, all fixed:

  • ❌ The new tests did not pin the analyzer, so under the old-analyzer job and under
    compatibility randomization roughly two dozen statements diverged and every
    serverError INCOMPATIBLE_TYPE_OF_JOIN pin asserted the wrong error code. Fixed with a
    session SET, which covers both routes; a skip tag would have covered only the first.
  • ❌ A plan assertion read a join algorithm name through EXPLAIN PLAN description = 1,
    which does not print it under the legacy explain default, so that pin read 0 there.
    Converted to actions = 1.
  • ❌ The must-not-fire assertions were written so a settings change could disarm them; they
    now assert count() = 0 on the conversion under actions = 1.
  • ❌ Missing drops meant the tests could not run twice in one database, which the stress
    job does; the drops also needed ignore_drop_queries_probability = 0 because that job
    injects a 0.2 skip probability.
  • ⚠️ The one-sidedness of the nullability clause was undocumented and pinned in only one
    direction. Both directions are now pinned: a nullable left key must not take the
    rewrite (it works today), a nullable storage key must (it does not).

Final round: 0 findings. Remaining notes, recorded and not blocking: two of the
pinned rows derive their expected value from the conversion's semantics rather than from
an adjacent ENGINE = Memory oracle row, and one code comment names a subset of the type
families its predicate excludes.

Independently re-verified while adjudicating, rather than taken from the implementation's
notes: the per-block key check strips Nullable and LowCardinality on both sides
(JoinUtils.cpp:480-481), so the left key becoming Nullable is tolerated; a NULL join
key never matches, which is what makes the conversion's NULL a domain-miss marker
(JoinUtils.cpp:462-464 and :409-412); the hash method is chosen from the storage side
(HashJoin.cpp:230-260), so the reused table stays valid; the only custom-named native
integer in the tree is Bool, so that guard's carrier set is closed; and the Join-engine
path returns before the algorithm loop (PlannerJoins.cpp:1396-1415), so no
join_algorithm value can perturb it.

Session id: cron:clickhouse-review-slot-52:20260729-033600

@groeneai

Copy link
Copy Markdown
Collaborator Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. clickhouse local --queries-file repro.sql on a Join(ANY, LEFT, k) table keyed UInt32 joined with a UInt64 column raises Code: 264 ... '_CAST(__table2.k, 'UInt64'_String)' was found 100% of the time. Not probabilistic, no randomization needed.
b Root cause explained? predicateOperandsToCommonType casts both keys to getLeastSupertype. Its storage-join carve-out skips the right cast only when the right type equals the common type up to nullability, so a width mismatch still emits _CAST on the storage key; column_mapping in chooseJoinAlgorithm only knows bare storage column names, so the key misses and the join is refused.
c Fix matches root cause? Yes. A StorageJoin's hash table is built at INSERT time and cannot be re-keyed, so the conversion is moved to the left key (accurateCastOrNull to the storage integer) at the function that decides the key types. No widened bound, no no-random-* tag, no reduced dataset, no guard at the throw site.
d Test intent preserved / new tests added? Two new stateless tests, 70 asserted statements including 10 serverError pins: oracle comparisons against an ENGINE = Memory twin, FilledJoin plan assertions per fixed carrier, and a serverError pin for every declining carrier. No existing test weakened. 03786_storage_join_type_conversion briefly regressed during development and was restored by correcting the fix, not by editing the test. Both tests are pinned against the Stress job's --client-option injections (join_use_nulls, join_algorithm, enable_analyzer) so no assertion can become silently vacuous or fail there; neither carries a no-random-* or no-parallel tag. The analyzer pin is a session SET, which also defends against the compatibility randomization (the analyzer default flipped in 24.3) because applyCompatibilitySetting skips a setting the session already changed; a no-old-analyzer tag would instead drop the tests from that lane. The one plan pin that reads a join algorithm name is asserted through EXPLAIN PLAN actions = 1, which is setting-independent, rather than description = 1, which is absent under the legacy explain default.
e Both directions demonstrated? Yes, same command: pristine master binary raises Code: 264; this branch returns 60 / [(0,30),(2,10),(4,20),(7,0),(4294967296,0),(4294967298,0)], equal to the oracle. Build IDs verified on both. 15 mutations each move a named assertion (e.g. plain CAST gives 100 with fabricated pairs; dropping the custom-name guard gives 14/21 for Bool/Nullable(Bool); removing either settings pin reddens the test under the matching stress injection). 50 randomized + 50 non-randomized runs of both tests: 100/100 and 100/100 pass. Each settings pin is separately proven load-bearing by removing it: with the analyzer pin removed both tests fail under enable_analyzer=0 and under compatibility='23.8' (Code: 53 ... Can't change type for right table: r.k: UInt32 -> UInt64 / TYPE_MISMATCH) while the committed versions stay green; the converted plan pin prints 1 under default, legacy and compatibility='25.1' where the old description = 1 spelling printed 0/0.
f Fix is general across code paths? The change is in the shared key-typing path, so USING, ON, ANY/ALL/SEMI/INNER, LEFT/RIGHT/FULL, and single- and multi-key storages are all covered by one hunk, each pinned by a test. Sibling widening site TableJoin::inferJoinKeyCommonType is the old-analyzer path, which fails earlier with TYPE_MISMATCH and is stated out of scope. Key-value entities excluded on purpose and pinned by a test. The related interposed-step bypass is a separate root cause, deliberately not bundled.
g Fix generalizes across inputs (params/datatypes/wrappers)? Wrapper matrix measured on both sides: Nullable, LowCardinality, LowCardinality(Nullable), plain. Boundaries: 0, in-range, above UInt32 max, exactly 2^32, negative, NULL. Truncating and domain-collapsing families (Float64, Decimal64(1), DateTime64(3), Bool, Nullable(Bool)) each measured to fabricate matches and therefore declined with a recorded serverError assertion rather than assumed safe. The optimization is asserted to still FIRE across the matrix (FilledJoin), not just that results are correct. The projected USING key's declared type is asserted too (UInt64, i.e. the left key's type, unchanged by the rewrite).
h Backward compatible? Yes. No setting added or defaulted differently, so no SettingsChangesHistory.cpp entry; no serialization or format change. No query that succeeds today changes its answer, pinned by the non-regression assertions and by the pre-existing 03786_storage_join_type_conversion.
i Invariants and contracts preserved? The right key expression remains the bare storage column, upholding the column_mapping contract (EXPLAIN: Join conditions: accurateCastOrNull(k, 'UInt32') = k). The direct FilledJoin path is preserved, so the engine's own strictness and key checks still apply rather than being bypassed. Comparison semantics are unchanged (oracle equality on every carrier; a NULL from the conversion does not match a NULL storage key). The new branch is reachable only under four conjoined conditions; all other joins take byte-identical paths.

Session id: cron:clickhouse-impl-slot-41:20260728-222200

@groeneai

Copy link
Copy Markdown
Collaborator Author

cc @nickitat @vdimir - could you review this? predicateOperandsToCommonType skips the right-side cast for a special storage only when the right type matches up to nullability, so a Join-engine key that is narrower than the left key still got a _CAST that column_mapping cannot resolve (INCOMPATIBLE_TYPE_OF_JOIN); since that hash table is built at INSERT time and cannot be re-keyed, the conversion moves to the left key as accurateCastOrNull, restricted to canonical native integers so an out-of-domain left value yields NULL and matches nothing.

@PedroTadim PedroTadim added the can be tested Allows running workflows for external contributors label Jul 29, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [b0d77ff]

Summary:

job_name test_name status info comment
Stress test (amd_tsan) FAIL
Logical error: A data stream row of AlignStreams has no matching metadata stream row (block number A, block offset B) (STID: 0250-16db) FAIL cidb

AI Review

Summary

This PR changes the analyzer-planned Join-engine path so a narrower native-integer storage key keeps the bare right key and narrows the left key with accurateCastOrNull. I checked the current diff, the touched JoinStepLogical / TableJoin / HashJoin paths, and the full prior PR discussion; the current head addresses the earlier bot-raised issues, and I did not find a remaining correctness, compatibility, or evidence gap that warrants a new inline comment.

Final Verdict

✅ No remaining review findings on the current PR head.

LLVM Coverage Report

Measured on commit b0d77ff.

Metric Baseline Current Δ
Lines 88.40% 88.40% +0.00%
Functions 92.00% 92.00% +0.00%
Branches 80.70% 80.70% +0.00%

Changed lines: Changed C/C++ lines covered: 38/38 (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 Jul 29, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.40% 86.30% -0.10%
Functions 91.90% 91.90% +0.00%
Branches 78.50% 78.50% +0.00%

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

Full report · Diff report

@groeneai

Copy link
Copy Markdown
Collaborator Author

CI finish ledger - c58d3d7

Every failure below has an owner: a fixing PR (ours 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 (amd_llvm_coverage, ParallelReplicas, s3 storage, parallel) / 01666_merge_tree_max_query_limit the per-table concurrency throttle the test asserts is keyed on query id, and secondary parallel-replicas reads are sent without one, so the holder query competes against itself (3/3 reruns, deterministic) #112385 (ours, open)
Stateless tests (amd_msan, WasmEdge, parallel, 2/2) / Bad cast from DB::ColumnNullable to DB::ColumnString (STID 4350-5ca2) + Server died (one abort, two rows) trunk defect in MergeTreeDataPartWriterOnDisk::initColumnsSubstreamsIfNeeded reached on the mutation path a fix task is moved to pending (investigating at full effort - fixing PR link to follow on this pull request)
Bugfix validation (functional tests, amd64/aarch64) / 04653_storage_join_narrow_key_type_kv not a failure: these rows are the regression test failing against the master binary as the Bugfix validation gate requires, and both checks report success -

Session id: cron:our-pr-ci-monitor:20260729-120000

@groeneai

Copy link
Copy Markdown
Collaborator Author

The fix task for the Bad cast from type DB::ColumnNullable to DB::ColumnString mutation-write-path logical error (STID 4350-5ca2, and its siblings 5793-5883 / 2241-7fcf / 4350-6700 / 5793-6473) has opened as #112497, as promised on the ledger line above.

Root cause: MutationHelpers::getColumnsForNewDataPart chose each column's recorded type before MutateTask::prepare decided between a full-part rewrite and a partial one, so after a metadata-only T -> Nullable(T) ALTER a full rewrite recorded the stale source-part type while the mutation pipeline wrote storage-typed columns. initColumnsSubstreamsIfNeeded then paired a ColumnNullable with SerializationString. Not caused by this PR.

@groeneai

groeneai commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Discharging the ledger promise on the Stateless tests (amd_msan, WasmEdge, parallel, 2/2) line above,
which read "a fix task is moved to pending (investigating at full effort - fixing PR link to follow on
this pull request)".

The fixing PR is #112501 ("Record the storage column type
when a mutation rewrites the whole part"), merged 2026-08-05T13:08:56Z. It supersedes my own #112497,
which I closed as a duplicate. Not caused by this pull request.

I verified the fix rather than assuming it, in both directions on the same repro:

  • On a pre-fix debug binary the reduced case aborts with Bad cast from type DB::ColumnNullable to DB::ColumnString, frame for frame the signature reported here: typeid_cast <-
    SerializationString::serializeBinaryBulk <- ISerialization::serializeBinaryBulkWithMultipleStreams
    <- MergeTreeDataPartWriterOnDisk::initColumnsSubstreamsIfNeeded <- MergeTreeDataPartWriterWide::write
    <- MergedBlockOutputStream::write <- PartMergerWriter::mutateOriginalPartAndPrepareProjections.
  • On a post-fix binary the same case completes cleanly, the mutation still runs, and the resulting part
    records Nullable(String) with substreams ['s.null','s.size','s'].

On master the signature is gone: keyed on the initColumnsSubstreamsIfNeeded frame with
head_ref = 'master', the last occurrence is 2026-08-04, i.e. before the merge, against 9.7 million
passing master-lineage stateless rows over 80 shas since. The predicate itself still matches the family
(84 rows in the pre-fix window), so the zero is a real absence rather than a broken filter.

One caveat worth stating: #112501 was not backported, so 26.7, 26.6, 26.5, 26.3 and 25.8 still
carry the defect. I have asked for v26.7-must-backport on
#113891 rather than labelling anything myself.

…m the two StorageJoin tests

Requested on ClickHouse#112005: the pins are unnecessary, so they should not be added.

They were added to keep the cleanup DROPs deterministic under the Stress job, which injects
`ignore_drop_queries_probability = 0.2`. That buys nothing. The default is 0
(`src/Core/Settings.cpp`) and the only injectors are `ci/jobs/scripts/stress/stress.py` (0.2)
and the upgrade check's `--fake-drop` client (`programs/client/Client.cpp`);
`tests/clickhouse-test` never injects it. Neither injecting job compares per-test results:
`tests/docker_scripts/stress_runner.sh` writes a single row built from the wrapper's own exit
code, and `stress.py` collects per-test return codes only to count finished processes before
returning, so a nonzero `clickhouse-test` exit is discarded. `upgrade_runner.sh` uses the same
wrapper pattern.

Both tests run under `-- { echo }`, so the statement text is the expected output and the
`.reference` files carry the same clauses; all four files are edited in lockstep. The
`DROP` statements themselves are unchanged (94 and 8), and the comment that existed only to
justify the pin is removed with it. The surrounding shared-database rationale is kept: it
explains why the tests drop first at all, which is still true.
@groeneai groeneai added the groeneai-origin-request PR origin: a maintainer pinged or directed groeneai label Aug 19, 2026
alexey-milovidov and others added 2 commits August 27, 2026 20:19
The branch had drifted into a merge conflict, so GitHub could not compute the
merge ref and the `PR` workflow (and with it `CH Inc sync`) never started.

Two conflicts, both mechanical:
- the include block, where both sides added different headers: keep the union;
- the signature of `predicateOperandsToCommonType`, which master reformatted and
  extended with `shared_runtime_filter_descriptors`: keep master's signature and
  the branch's two new static helpers above it.
…llable

`TableJoin::getRequiredRightKeys` promotes a required right key to Nullable when
the paired left key is Nullable, so that an unmatched-left row fills a genuine
NULL which `firstNonDefault` prefers over the storage default. The planner
registered every corrected right key as a candidate for that promotion,
including a key corrected by a plain cast to a non-Nullable type.

The narrowing rewrite on this branch casts the left key with
`accurateCastOrNull`, which is Nullable even when neither the left column nor
the storage key is. A LEFT/FULL USING join therefore reached that combination:
the join emitted `__table2.k` as `Nullable(UInt32)` while the post-join
correction had been typed against the storage key and declared
`_CAST(__table2.k, 'UInt64') -> UInt64`, so executing it raised

    Unexpected return type from _CAST. Expected UInt64. Got Nullable(UInt64)

Register a corrected right key only when its corrected type is itself Nullable.
A genuinely Nullable left key always makes the common type Nullable, so the
promotion still fires for the case it was written for.

Caught by section 13 of 04652_storage_join_narrow_key_type after merging master.
@groeneai

Copy link
Copy Markdown
Collaborator Author

Fast test red on the master merge: fixed in e619e32

The red was section 13 of 04652_storage_join_narrow_key_type, Code: 49 Unexpected return type from _CAST. Expected UInt64. Got Nullable(UInt64). It is not the conflict resolution. It is a composition
with #111371, which merged after this PR was published.

TableJoin::getRequiredRightKeys promotes a required right key to Nullable when the paired left key is
Nullable, so an unmatched-left row fills a genuine NULL. The narrowing rewrite here casts the left key
with accurateCastOrNull, which is Nullable even when neither the left column nor the storage key is,
so a LEFT/FULL USING join reached that combination: the join emitted __table2.k as Nullable(UInt32)
while the post-join correction had been typed against the storage key and declared
_CAST(__table2.k, 'UInt64') -> UInt64.

The planner now registers a corrected right key only when its corrected type is itself Nullable. A
genuinely Nullable left key always makes the common type Nullable, so #111371 still fires for the case
it was written for: its own test 04545_full_join_storage_join_using_nullable_key passes, and the
issue #103205 repro still returns NULL for the unmatched-left row on both binaries.

Verified on a local debug build: the unfixed binary aborts on that statement with the same message and
the fixed one returns the reference rows. 04652, 04653, 04545, 03786 and 03787 pass. The whole
join family, 1063 tests, was run against both binaries: the failing sets are identical, with no
difference in either direction.

Comment thread tests/queries/0_stateless/04652_storage_join_narrow_key_type.sql
Comment thread tests/queries/0_stateless/04652_storage_join_narrow_key_type.sql
The stress job runs odd workers with a single shared database for every test
(ci/jobs/scripts/stress/stress.py:373) and injects
ignore_drop_queries_probability=0.2 (:454). Under that setting
InterpreterDropQuery ignores a DROP of a table that stores data on disk and
rewrites the rest to TRUNCATE (InterpreterDropQuery.cpp:220-228), so a cleanup
DROP is not guaranteed and a table can outlive the test that created it.

Two of the 51 tables these two tests create had a name another stateless test
also creates: big (03394_distributed_broadcast_join,
04545_negative_limit_distinct) and kv (00830_join_overwrite). Both are now
suffixed with the test number, so no name is shared any more. The remaining 49
are already unique.

The statements are echoed, so the reference moves with the .sql. Both tests
pass with and without setting randomization, and a corrupted reference line
still fails.
The block also described what widening the right key would do and named the
error code that produces. That is a rejected design, not an invariant holding
at this line. What a reader needs here is that the hash table is keyed on the
declared storage key type and cannot be rebuilt at query time, and that the
`Nullable` the conversion introduces is safe because an out-of-domain NULL
never matches.
@clickhouse-gh

clickhouse-gh Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

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

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 712.37 MiB 709.31 MiB -3.06 MiB (-0.43%)

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

Compile time of recompiled translation units

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

Job report

@groeneai

Copy link
Copy Markdown
Collaborator Author

CI finish ledger - b0d77ff

Every failure below has an owner: a fixing PR (ours 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
Stress test (amd_tsan) / Logical error: A data stream row of AlignStreams has no matching metadata stream row (STID 0250-16db) trunk regression, not PR-caused: 30 rows over 25 other branches plus 3 rows on master in 30 days, every one of them dated 2026-08-26 10:46Z or later, across 9 build flavours #116531 (external, open)
Bugfix validation (functional tests, amd64/aarch64) / 04653_storage_join_narrow_key_type_kv not a failure: these rows are the regression test failing against the master binary as the Bugfix validation gate requires, and both checks report success -

Nothing else is red at this commit: Fast test, Fast test (arm_darwin), Style check, the nine other Stress test flavours and CH Inc sync are all green.

Session id: cron:our-pr-ci-monitor:20260828-070000

@clickhouse-gh clickhouse-gh Bot added the comp-joins JOINs end-to-end (planning hooks + runtime join operators/algorithms). Single bucket to avoid pla... label Sep 4, 2026
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-joins JOINs end-to-end (planning hooks + runtime join operators/algorithms). Single bucket to avoid pla... groeneai-origin-request PR origin: a maintainer pinged or directed groeneai pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StorageJoin cast skipping in buildJoinClausesAndActions fails when StorageJoin has smaller type than left table

3 participants