Fix LOGICAL_ERROR in HashJoin::buildAdditionalFilter with join_use_nulls and a non-equi JOIN condition#107957
Conversation
|
cc @vdimir @KochetovNicolai — could you review this? It fixes an |
|
Workflow [PR], commit [1b5b608] Summary: ❌
AI ReviewSummaryThis PR fixes the Findings
Final Verdict
|
|
Follow-up commit 4884b84 fixes a CI failure on the The regression queries use a non-equi ON conjunct ( Verified with the test runner under Session id: cron:clickhouse-worker-slot-0:20260619-210000 |
|
CI finished on The remaining red is expected, not a defect:
Session id: cron:our-pr-ci-monitor:20260620-020000 |
|
@groeneai Your repro CREATE TABLE l (k1 String, k2 String, val UInt32) ENGINE = Memory;
CREATE TABLE r (k1 String, k2 String, val UInt32) ENGINE = Memory;
INSERT INTO l VALUES ('foo','bar',1),('foo','baz',2),('qux','bar',3);
INSERT INTO r VALUES ('foo','bar',10),('foo','baz',20),('xyz','abc',30);
SELECT COUNT(DISTINCT *)
FROM l AS l LEFT JOIN r AS r
ON (l.k1 = r.k1) AND (l.k2 != r.k2)
SETTINGS join_use_nulls = 1;goes through without error. Please find an actual repro first. |
|
@rschu1ze You are right, that Memory example does not reproduce. The PR description was misleading and I have corrected it. The crash needs a key-value-storage table on the right side of the join (EmbeddedRocksDB or a COMPLEX_KEY_HASHED dictionary), not Memory/MergeTree. With a KV right side, Working repro (aborts on an unfixed debug/sanitizer build, returns CREATE TABLE rl (k1 String, k2 String, val UInt32) ENGINE = EmbeddedRocksDB PRIMARY KEY (k1, k2);
CREATE TABLE rr (k1 String, k2 String, val UInt32) ENGINE = EmbeddedRocksDB PRIMARY KEY (k1, k2);
INSERT INTO rl VALUES ('foo','bar',1),('foo','baz',2),('qux','bar',3);
INSERT INTO rr VALUES ('foo','bar',10),('foo','baz',20),('xyz','abc',30);
SELECT COUNT(DISTINCT *) FROM rl AS l LEFT JOIN rr AS r ON (l.k1 = r.k1) AND (l.k2 != r.k2)
SETTINGS join_use_nulls = 1, join_algorithm = 'direct,hash', enable_analyzer = 1;The fasttest-compatible regression test |
| /// The destination column type comes from the residual filter expression's required | ||
| /// columns, which reference the raw (non-promoted) right-table inputs. The saved | ||
| /// right block, however, may carry join-output nullability (join_use_nulls + outer | ||
| /// join). Reconcile the two nullability representations, like the sibling join-output | ||
| /// builders do (AddedColumns::buildJoinGetOutput, AddedColumns::checkColumns). | ||
| if (auto * nullable_dest = typeid_cast<ColumnNullable *>(col.get()); nullable_dest && !src_col->isNullable()) |
There was a problem hiding this comment.
Normally we keep original keys non nullable for join_use_nulls as join keys and output caster columns, it should be handled on planning stage or when we convert join logical step to physical
There was a problem hiding this comment.
@vdimir Agreed, this belongs upstream. The seam:
For a regular (Memory/MergeTree) right side the saved right block stays non-nullable; join_use_nulls is applied only to the OUTPUT columns (AddedColumns builds them Nullable, reconciling via insertFromNotNullable). The residual/mixed-filter required columns also stay non-nullable, because JoinStepLogical folds away the toNullable wrappers (JoinStepLogical.cpp:1230-1249). Saved block and residual required types agree, so there is no divergence.
The key-value lookup path is the anomaly. JoinStepLogical.cpp:1336-1343:
if (logical_lookup && prepared_join_storage.storage_key_value)
right_dag.mergeInplace(... actions_after_join, fromRight ...);merges the toNullable-wrapped right columns into right_dag, so for KV joins only, right_sample_block (and the HashJoin saved block data->sample_block) becomes Nullable. The residual required types are still non-nullable, so buildAdditionalFilter builds the dest from the non-nullable type and fills from the Nullable saved block, tripping assertTypeEquality.
Planning-stage fix: keep the KV-storage right_sample_block non-nullable like regular joins and apply join_use_nulls on output, instead of baking it into the block the physical join consumes. That removes the divergence at the source and drops the buildAdditionalFilter reconciliation.
Before I rewrite: is keeping right_sample_block non-nullable in that KV branch the direction you want (this is also the logical->physical conversion you mentioned), or would you prefer it handled elsewhere? Confirming first since it touches the KV/direct-join output path. The current fix and the 03720/04338 regression tests stay on the branch until then.
A non-equi ON conjunct (e.g. l.k2 != r.k2) becomes a residual (mixed) filter. Its required-column types reference the raw right-table inputs (non-Nullable), because the residual filter DAG folds away the toNullable wrappers in JoinStepLogical. Under join_use_nulls with an outer join, however, the right column is stored in the saved join block as Nullable. buildAdditionalFilter built the destination column from the residual filter's required type (e.g. ColumnString) and then inserted from the saved-block column (ColumnNullable), tripping IColumn::assertTypeEquality and aborting in debug/sanitizer builds. Reconcile the two nullability representations when filling the residual filter input column, mirroring the existing join-output builders (AddedColumns::buildJoinGetOutput and AddedColumns::checkColumns). Matched rows reaching the residual filter are never null, so inserting the nested value is correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ilter The existing regression lives in 03720_rocksdb_multicolumn_primary_key, which is tagged no-fasttest (EmbeddedRocksDB needs ENABLE_LIBRARIES), so it does not run in fasttest/normal stateless builds. The crash is not specific to EmbeddedRocksDB: it is triggered by any key-value storage join (JoinStepLogicalLookup), where under join_use_nulls the right side reaches the saved join block already promoted to Nullable while the residual filter expression references the raw non-Nullable input. A complex-key dictionary is also a key-value entity and reproduces the exact same buildAdditionalFilter abort, but runs in fasttest. Plain Memory/MergeTree tables do not promote the right header before the join, so they cannot reproduce it. The new test uses a COMPLEX_KEY_HASHED dictionary on the right side and forces join_algorithm = 'hash'. It is parallel-safe (objects scoped to the test database). Verified to abort without the fix and pass with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ents A LOGICAL_ERROR is a thrown exception that release builds do not terminate on; only debug/sanitizer builds abort on the assertion. Use exception wording in the regression-test comments per ClickHouse convention. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The buildAdditionalFilter regression queries use a non-equi ON conjunct (l.k2 != r.k2), which only the new analyzer supports. The old analyzer rejects it with INVALID_JOIN_ON_EXPRESSION, so the test failed on the old-analyzer CI variant. Pin enable_analyzer=1 on those queries (03720) and the dictionary test (04338) so coverage runs under the new analyzer regardless of the runner's analyzer mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4884b84 to
1b5b608
Compare
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 8/9 (88.89%) · Uncovered code |
CI finish ledger — 1b5b608Every 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
Session id: cron:our-pr-ci-monitor:20260704-150000 |

Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fix a
LOGICAL_ERRORexception (IColumn::assertTypeEquality) when aJOINwith a non-equi condition in theONclause (a residual/mixed filter, e.g.l.a = r.a AND l.b != r.b) is executed withjoin_use_nulls = 1against a key-value-storage right table (EmbeddedRocksDB or a dictionary).Description
Found by the stress fuzzer (CIDB STID 2508-30f6), reproduced deterministically locally.
No related open issue found (the long-standing
assertTypeEqualitySTID-2508 issues cover other code paths and are closed; thisHashJoin::buildAdditionalFilterpath was not covered by any of them).CI reports:
Stress test (amd_msan): https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=107932&sha=b28976ac66b43f70f4b1d49f9a0207d81ed44550&name_0=PR&name_1=Stress%20test%20%28amd_msan%29Stress test (amd_tsan): https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=101785&sha=c4ae74a04aa2b2ae73cda996c28a6479e887a415&name_0=PR&name_1=Stress%20test%20%28amd_tsan%29(Both are unrelated PRs that merely hit the pre-existing trunk bug; 0 occurrences on master.)
Reproducer
The crash requires a key-value-storage table on the right side of the join (EmbeddedRocksDB or a COMPLEX_KEY_HASHED dictionary). Memory/MergeTree right sides do not reproduce it (see root cause).
Aborts in debug/sanitizer builds with:
Logical error: '... typeid(*this) == typeid(rhs)'inIColumn::assertTypeEquality, viaHashJoin::buildAdditionalFilter(HashJoinMethodsImpl.h).Root cause
A non-equi
ONconjunct (l.k2 != r.k2) becomes a residual (mixed) filter whose required-column types reference the raw right-table inputs: inJoinStepLogicalthe residual DAG folds away thetoNullable(x)wrappers and keeps the bare non-NullableInputnode. With a key-value-storage right side,JoinStepLogicalLookuppromotes that same right column toNullablein the saved join block underjoin_use_nulls = 1and an outer join. (Memory/MergeTree right sides have no lookup step, so the right header is never promoted and the bug does not trigger.)In
buildAdditionalFilter, the destination column is created from the residual filter's required type (ColumnString), then filled byinsertFromfrom the saved-block column (ColumnNullable). Thetypeiddiffers, soIColumn::assertTypeEqualityaborts.Fix
Reconcile the two nullability representations when filling the residual-filter input column, mirroring the existing join-output builders (
AddedColumns::buildJoinGetOutput,AddedColumns::checkColumns): if the destination isNullableand the source is not, useinsertFromNotNullable; if the destination is notNullableand the source is, insert from the source's nested column. Matched rows reaching the residual filter are never null (output nullability only applies to non-matched outer rows, which the residual filter does not evaluate), so inserting the nested value is correct.Regression tests:
04338_hash_join_additional_filter_nullable(COMPLEX_KEY_HASHED dictionary, fasttest-compatible) and03720_rocksdb_multicolumn_primary_key(EmbeddedRocksDB).