Fix text index direct read losing NULL semantics for Nullable columns#107078
Fix text index direct read losing NULL semantics for Nullable columns#107078groeneai wants to merge 1 commit into
Conversation
|
cc @ahmadov @CurtizJ - could you review this? When the indexed column is |
|
Workflow [PR], commit [b603d9c] Summary: ❌
AI ReviewSummaryThis PR moves the Findings❌ Blockers
Tests
Final VerdictStatus: ❌ Block Minimum required action: preserve the effective post-preprocessor haystack null map for direct reads, and add the null-producing preprocessor regression above. |
CI status — all failures triaged (commit 4ac4f34)CI has fully finished (Finish Workflow / Mergeable green). The single red functional check is a pre-existing flaky test, not caused by this PR (which touches only
All other checks passed. Nothing in this PR's code path failed. Ready for review. |
Pre-PR validation gate (scope-extension update)This update extends the fix to the Hint-mode functions after @ qoega reported the same defect via
Session id: cron:clickhouse-worker-slot-2:20260612-075800 |
Pre-PR validation gate (commit df64f0e, mapValues fix)
Session id: cron:clickhouse-worker-slot-2:20260612-092500 |
|
CI fully finished on HEAD This HEAD adds the central nullable-haystack demotion ( No PR-caused failures. Ready for review. |
Ergus
left a comment
There was a problem hiding this comment.
This fix seems to disabled all direct read for any nullable column. That is not a correct approach because it may affect performance very strongly.
There should be another root cause somewhere else. Let's say that a nullable column when we remove the nullable wrapper, it must have empty entries in the null rows, so the index must have nothing in those rows, so no matches. When direct read fills the virtual column those rows must have 0 and negating them must be 1 as expected. This is not happening at the moment, so this fix is just a way around to hide the real problem.
|
@groeneai The real issue is probably in When This demoting to None in traverseFunctionNode prevents the virtual column from ever being created for nullable haystacks, which avoids the bad cast, but kills the optimization for all predicates rather than fixing the cast itself. |
|
@Ergus You're right on both counts, and your root cause is exact. Reworked the fix along the lines you described (pushed in 4980a85). The defect was the type-mismatch cast in Fix: at that central virtual-column site, for a Nullable haystack I replaced the lossy cast with I removed the demote-to-None entirely, so direct read stays enabled for Nullable columns (your performance concern). Verified on a Nullable text-indexed column:
The |
Pre-PR validation gate (commit 4980a85, @Ergus redesign)
Session id: cron:clickhouse-worker-slot-6:20260615-135600 |
| if (preserve_haystack_null) | ||
| { | ||
| auto is_null_builder = FunctionFactory::instance().get("isNull", context); | ||
| const auto & is_null_node = actions_dag.addFunction(is_null_builder, {original_haystack}, ""); |
There was a problem hiding this comment.
This still restores only the original source null map, not the effective haystack null map after the text-index preprocessor. For a nullable source column with a null-producing preprocessor, the no-direct-read path has a well-defined nullable predicate, but this wrap tests isNull(str) instead of isNull(nullIf(str, '')).
Concrete trace:
CREATE TABLE tab
(
id UInt32,
str Nullable(String),
INDEX idx(str) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = nullIf(str, ''))
)
ENGINE = MergeTree ORDER BY id;
INSERT INTO tab VALUES (1, 'hello'), (2, ''), (3, 'foo'), (4, NULL);
SELECT id FROM tab WHERE NOT hasToken(str, 'hello') ORDER BY id;With query_plan_direct_read_from_text_index = 0, preprocessing evaluates the predicate as hasToken(nullIf(str, ''), 'hello'), so row 2 is NULL and NOT NULL filters it out. With the current direct read, the index skipped row 2, the virtual column reads 0, and if(isNull(str), NULL, 0) keeps it non-null, so NOT 0 returns row 2.
Please derive the guard from the post-preprocessor haystack when it can be nullable, and add a regression with Nullable(String) plus preprocessor = nullIf(str, '').
|
CI fully finished on HEAD
All four |
400afea to
91f1feb
Compare
|
Rebased onto current master (HEAD The conflict was mechanical only: master renamed Re-verified on the rebased binary (build id
|
CI finish ledger — 91f1feb (post rebase)CI has fully finished (Finish Workflow + Mergeable Check passed; last check 2026-06-25 15:37). This PR fixes text-index direct-read NULL semantics; the single red is an unrelated chronic deadlock. Every red check has an owner; only
The fix (nullable-haystack demotion at all Exact construction sites + isNull guard) + 02346 regression test are green; @ Ergus's CHANGES_REQUESTED was addressed in the rebased commit. Ready for re-review. Session id: cron:our-pr-ci-monitor:20260625-170000 |
91f1feb to
5b03f81
Compare
|
Rebased onto current master to resolve a fresh merge conflict. The only conflicting file was Verified master still carries the bug: the lossy Re-verified on the rebased binary (build id c4a32c4a): core repro |
CI finish ledger — 5b03f81Every 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:20260626-160000 |
With a text skip index on a Nullable (or LowCardinality(Nullable)) column, queries like NOT hasToken(str, 'x'), NOT hasAllTokens(...) and NOT hasAnyTokens(...) wrongly returned rows whose indexed value is NULL. hasToken(NULL, 'x') is NULL and NOT NULL is NULL, so a NULL row must be filtered out (which is what happens when the index is not used). The direct-read-from-text-index optimization replaces these functions with a virtual column read from posting-list membership. That column is a plain UInt8: a NULL row has no tokens, so it materialized as 0, indistinguishable from a non-matching row. When the original predicate is Nullable (because the haystack is Nullable), the type mismatch path added CAST(virtual_column, 'Nullable(UInt8)'), which manufactures an all-false null map: every row is declared non-NULL, so the lost NULLs are not recovered and NOT 0 = 1 kept the NULL row. Fix the cast site. For a Nullable haystack, wrap the replacement as if(isNull(haystack), NULL, virtual_column): the haystack's own null map drives the result, matching f(NULL, ...) = NULL. This keeps direct read enabled (granule pruning still applies) and covers Exact, Hint and the mapValues re-promotion uniformly because it keys on the haystack rather than the posting-list membership. The haystack is located as the non-constant Nullable argument, so comparisons like equals that carry the indexed column on either side are handled too. For partial materialization the Exact-mode default expression evaluates the real function for non-materialized granules; coalesce it to 0 with ifNull(real_func, 0) so a NULL row can be inserted into the UInt8 virtual column, and let the outer wrap restore the NULL. Extends 02346_text_index_bug106922 with NOT hasToken/hasAllTokens/hasAnyTokens, LIKE/ILIKE, Hint-mode and Map(String, Nullable(String)) mapValues regressions, plus an assertion that the virtual column is still read directly and guarded by isNull(haystack) for a Nullable column, so a regression that falls back to a full scan is caught. Closes: ClickHouse#106922 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5b03f81 to
b603d9c
Compare
|
Rebased onto current master ( The conflict was mechanical: master reapplied "Text index postprocessor" (#108606), restoring the One test-only adjustment: the "direct read stays enabled" EXPLAIN assertion now also accepts Re-verified on the rebased binary (build id
|
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 38/38 (100.00%) · Uncovered code |
CI finish ledger — b603d9cCI fully finished (Finish Workflow: success). This PR fixes text-index direct read losing NULL semantics for Nullable columns. Every failure has an owner; none is PR-caused. Session id: cron:our-pr-ci-monitor:20260630-163000 |

Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed wrong results from a
textindex on aNullable(orLowCardinality(Nullable)) column. A negated text-search predicate (NOT hasToken(col, 'x'), and likewisehasAllTokens,hasAnyTokens,hasPhrase,startsWith,endsWith,LIKE) no longer returns rows whose indexed value isNULL. The direct-read-from-text-index optimization materialized a non-Nullable0/1for such rows, losing theNULLthat the function returns, so negation wrongly kept theNULLrows.Description
Found by the AST-fuzzer correctness oracle (issue #106922, reproducible on default settings). The reporter later confirmed the same defect surfaces via
match,likeandhasPhrase(in addition tohasToken), on both fully and partially materialized (ngrams) text indexes.Root cause: the direct-read-from-text-index optimization reads a non-
NullableUInt8virtual column from posting-list membership. ANULLrow has no tokens, so it reads as0(indistinguishable from a non-matching row), and the index stores no per-row null map (NULL rows are skipped at build time), so the mask cannot be reconstructed from the index alone. This0then loses theNULLin both direct-read modes:Exactmode (hasToken/hasAllTokens/hasAnyTokens) fully replaces the function with the virtual column; the later cast toNullable(UInt8)only adds an all-false null map, soNOT 0 = 1keeps the row.Hintmode (hasPhrase/startsWith/endsWithand theLIKErewrites) buildsand(virtual_column, real_func); for aNULLrow that isand(0, NULL) = 0, soNOT 0 = 1keeps the row too.Fix: when the indexed (haystack) column is
Nullable/LowCardinality(Nullable), demote any non-Nonedirect read mode (ExactandHint) toNone, so the real function is evaluated per row. Granule-level pruning via the index still applies. The needle is always a non-null constant when direct read fires, so nullable-needle direct read,Array(Nullable)andmapValuespaths (where the array value itself is neverNULL) are unaffected, and the non-Nullablefast path is untouched.Verified locally (debug build): every negated predicate now returns the same rows as
SETTINGS query_plan_direct_read_from_text_index = 0; the regression test02346_text_index_bug106922covershasToken/hasAnyTokens/hasAllTokens/hasPhrase/startsWith/endsWith/match/LIKEoverNullableandLowCardinality(Nullable)haystacks plus anngramspartially-materialized index, and fails on a binary without the fix. Existing text-index tests pass.