Fix JOIN with a Join-engine table whose key type is narrower than the left key - #112390
Fix JOIN with a Join-engine table whose key type is narrower than the left key#112390groeneai wants to merge 13 commits into
Conversation
… 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.
Internal second-model review (click to expand)Two independent review passes ran against this change before it was opened: a cold Approach review (before implementation): 6 rounds, 7 findings, all accepted. Four
Code review (after implementation): 7 rounds, 14 findings from the adversarial pass
Final round: 0 findings. Remaining notes, recorded and not blocking: two of the Independently re-verified while adjudicating, rather than taken from the implementation's Session id: cron:clickhouse-review-slot-52:20260729-033600 |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-41:20260728-222200 |
|
cc @nickitat @vdimir - could you review this? |
|
Workflow [PR], commit [b0d77ff] Summary: ❌
AI ReviewSummaryThis PR changes the analyzer-planned Final Verdict✅ No remaining review findings on the current PR head. LLVM Coverage ReportMeasured on commit b0d77ff.
Changed lines: Changed C/C++ lines covered: 38/38 (100.00%) · Uncovered code |
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 32/32 (100.00%) · Uncovered code |
CI finish ledger - c58d3d7Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
Session id: cron:our-pr-ci-monitor:20260729-120000 |
|
The fix task for the Root cause: |
|
Discharging the ledger promise on the The fixing PR is #112501 ("Record the storage column type I verified the fix rather than assuming it, in both directions on the same repro:
On master the signature is gone: keyed on the One caveat worth stating: #112501 was not backported, so |
…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.
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.
|
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.
Build profile diff (arm_release)Comparing ✅ No significant changes. Binary sizes
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 units7 translation units recompiled, 24 s compile time in total, 7 of them have a recent master baseline. |
CI finish ledger - b0d77ffEvery failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
Nothing else is red at this commit: Session id: cron:our-pr-ci-monitor:20260828-070000 |
Closes: #104918
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed
INCOMPATIBLE_TYPE_OF_JOINwhen joining against a table with theJoinengine whose integer key type is narrower than the left table's key, for example aJointable keyed onUInt32joined with aUInt64column. The fix applies with the defaultenable_analyzer = 1. The planner used to widen the right key with a_CAST, which theJoinengine 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 withaccurateCastOrNullinstead, so a left value outside the storage key domain matches nothing and the directJoin-engine lookup is preserved. Closes #104918.Description
SELECT count() FROM big AS l ANY LEFT JOIN jt AS r USING (k), wherejtisENGINE = Join(ANY, LEFT, k)withk UInt32andbig.kisUInt64, failed:Code: 264 ... but column '_CAST(__table2.k, 'UInt64'_String)' was found. (INCOMPATIBLE_TYPE_OF_JOIN).predicateOperandsToCommonTypecasts 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_CASTon the storage key, whichchooseJoinAlgorithm'scolumn_mappinglookup -- knowing only the storage's bare column names -- then refused.Widening the right key cannot work: a
StorageJoin'sHashJoinis reused as built, so the stored key type cannot change at query time. The conversion moves to the left key instead, and it must beaccurateCastOrNullrather than a plainCAST, because a plainCASTwraps out-of-range left keys and fabricates matches (against anENGINE = Memoryoracle on the same rows: 60 with this PR, 100 with a plainCAST).accurateCastOrNullis 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/UInt32left keys under everyNullable/LowCardinalitywrapper combination againstUInt32andNullable(UInt32)storage keys, negative left values, theONspelling,IS NOT DISTINCT FROM,SEMI,ALL RIGHT/ALL FULLUSING, and multi-key storages. The test assertsFilledJoinis 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-keyJointable 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_JOINand is pinned with aserverErroror plan assertion, so it is a recorded decision, not an oversight.Decimal64(1)toInt32turns1.5into1,DateTime64(3)toDateTimedrops sub-seconds.Bool, whereaccurateCastOrNull(2, 'Bool')istrue, notNULL.LowCardinality, whichaccurateCastOrNullcannot target. Such keys are already refused even for a type-matched left key: a separate pre-existing defect.Int128/UInt128/Int256/UInt256): whetheraccurateCastOrNullis a pure domain check there needs its own verification.EmbeddedRocksDB,KeeperMap): no frozen hash table, and the cast is handled already.UInt32left againstInt16storage givesInt64), so the storage key would still be cast. Mixed signedness alone is not the boundary:Int64left againstUInt32is fixed.The reporter's observation that
WHERE materialize(1)makes the query succeed points at filter push-down, not constant-foldability: an interposedFilterStepabandons theJoin-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 workaroundON toUInt32(l.k) = r.kis unsafe too: it returns 100, not 60.With
enable_analyzer = 0the same query fails earlier and differently: the old analyzer'sTableJoin::inferJoinKeyCommonTyperaisesTYPE_MISMATCH, notINCOMPATIBLE_TYPE_OF_JOIN. This PR does not touch that path and its behaviour there is identical before and after, which is why both tests pinenable_analyzer = 1.Workflow [PR]
Sync PR [sync-upstream/pr/112390]