Skip to content

Fix text and token skip indexes over-pruning IPv6 columns - #113157

Open
groeneai wants to merge 3 commits into
ClickHouse:masterfrom
groeneai:fix-text-index-const-conversion-ipv6
Open

Fix text and token skip indexes over-pruning IPv6 columns#113157
groeneai wants to merge 3 commits into
ClickHouse:masterfrom
groeneai:fix-text-index-const-conversion-ipv6

Conversation

@groeneai

@groeneai groeneai commented Aug 3, 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 ngrambf_v1, tokenbf_v1 and sparse_grams skip indexes silently dropping matching rows when an IPv6 column is compared with a string literal, as in WHERE ip = '2001:db8::'. The index probe is now built from the constant converted into the indexed column's own type, so it matches the bytes the index stores.

Description

WHERE ip = '2001:db8::' over an ngrambf_v1 / tokenbf_v1 / sparse_grams index on an IPv6 column returned 0 rows instead of 1: a plain SELECT, no forced index, no unusual setting. LowCardinality(IPv6), the map-key subcolumn form on Map(IPv6, ...), and ip IN (SELECT ...) are affected the same way.

Root cause. The aggregator tokenizes the indexed column's raw bytes, for IPv6 the 16 bytes 20010DB800.... MergeTreeConditionBloomFilterText::traverseTreeEquals checked only the constant's type and tokenized the constant's own bytes, the ASCII 2001:db8::. The encodings share no prefix, so the bloom filter reported no match and the granule was pruned. The query is valid only because the comparison function converts separately at runtime; index analysis had no conversion step. The bloom_filter index does convert, and is correct here.

Change. Before building the probe, convert the constant into the indexed column's primitive type and re-serialize it through that type's own column, so the probe bytes are the bytes the index stores. The same conversion runs per element of an IN set, which had the same defect when the set comes from a subquery and so is not type-checked against the index. A constant that cannot be represented declines the atom, giving a full scan and a correct answer. String and FixedString keep the previous path via an early return, since there the constant already is the index encoding. Granules are untouched, so existing indexes stay valid and need no rebuild.

Validation. New test 04716_text_index_ipv6_string_constant uses a Log twin as the oracle and asserts granule counts via EXPLAIN indexes = 1, since a row count cannot see over-pruning. It covers all six carriers and keeps non-matching-address arms so pruning is not blanket-disabled. 26 assertions fail on unpatched master and pass here; 50 randomized runs are green, and a 373-test skip-index suite shows no regressions.

Reported by an automated review comment on #112868.

The ngrambf_v1 / tokenbf_v1 / sparse_grams aggregator tokenizes the raw bytes
of the indexed column, which for an IPv6 column are its 16 stored bytes.
MergeTreeConditionBloomFilterText::traverseTreeEquals gated on the CONSTANT's
type only and then tokenized the constant's own bytes, so for

    WHERE ip = '2001:db8::'

the granule held n-grams of 20010DB8000000000000000000000000 while the probe
was built from the ASCII 323030313A6462383A3A. The two encodings share no
prefix, so the bloom filter reported no match and the matching granule was
pruned: 0 rows instead of 1, on a plain SELECT with no forced index and no
unusual setting.

The query is only valid because the comparison function converts separately at
runtime (executeWithConstString). Index analysis had no counterpart; the
sibling bloom_filter index does convert and is correct on the same query.

Convert the constant into the indexed column's primitive type and re-serialize
it through that type's own column, so the probe bytes are the bytes the index
stores. A constant that cannot be represented there declines the atom, which
falls back to a full scan and a correct answer; tryConvertFieldToType is used
so index analysis cannot raise for a constant the comparison would never
evaluate. String and FixedString columns keep the previous code path exactly
via an early return, since there the constant already is the index encoding
and a constant wider than a FixedString must still be able to prune.

Affected carriers, all measured: ngrambf_v1, tokenbf_v1 and sparse_grams on
IPv6, ngrambf_v1 on LowCardinality(IPv6), and the map-key subcolumn form on a
Map(IPv6, ...) with a mapKeys index. Granules are untouched, so existing
indexes stay valid and need no rebuild.
…ndex

tryPrepareSetBloomFilter is a third arm with the shape the previous commit
repaired in traverseTreeEquals: it type-gates on the SET's element types and
then tokenizes their raw bytes, without converting them into the indexed
column's encoding.

The tuple form of IN was already safe by accident. RPNBuilderTreeNode::
tryGetPreparedSet resolves a literal tuple through PreparedSets::findTuple,
which requires equals(set->getTypes(), types) against the index data types, so
a String tuple set against an IPv6 index never matches and the atom declines.
A subquery resolves through findSubquery, which takes no types argument at all,
so a String-typed set clears the gate and builds ASCII probes against the 16
stored bytes:

    SELECT count() FROM t WHERE ip IN (SELECT '2001:db8::')

returned 0 where the oracle returns 1, with Granules: 0/64.
use_index_for_in_with_subqueries defaults to true.

Reuse convertConstantToIndexDomain per set row, and decline the whole atom if
any row cannot be represented in the index domain: a partially converted set
would under-approximate membership and could prune a granule that matches. A
String or FixedString domain keeps the previous code path byte for byte, since
there the set's own bytes already are the index encoding.

Also relabel the inverted notEquals test row. Skip index conditions are built
from an ActionsDAGWithInversionPushDown, whose inverse_relations renames
notEquals to equals under a NOT, so NOT (ip != 'lit') is carried by the equals
arm rather than by the notEquals arm. The row is a real carrier and stays; its
name no longer claims to observe something it cannot.
The map-key paths of MergeTreeConditionBloomFilterText::traverseTreeEquals
substitute the serialized map key for the compared value, but the constant
conversion added earlier in this PR was still handed the type of the
comparison's right-hand side. convertFieldToType dispatches on that type, and
it treats a FixedString(16) source as the binary form of an IPv6 address.

So for a mapKeys index over an IPv6-keyed map, WHERE m.`key_<addr>` =
toFixedString(v, 16) decoded the key's ASCII text as an address and probed the
index with the wrong bytes, dropping the matching granule and returning a wrong
result. Any other key-text length hit end of input instead, declined the atom
and silently lost a legitimate prune. A bare string literal happens to share the
key's own type, which is why this was not visible on the spellings already
covered.

Give the conversion the source type of the value it actually converts. The
mapValues sibling keeps the right-hand side's type because it does not
substitute the value. The arrayElement branch already captured the key's type
and now passes it on, although that branch is currently unreachable for an
IPv6-keyed map.
@groeneai

groeneai commented Aug 3, 2026

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

Three review rounds by an independent model that did not write the code, each
followed by a fix round. Round 3 is clean: my own cold re-review found nothing,
and the automated pass returned no findings.

Round 1 — 2 findings, both accepted and fixed

IN with a subquery still probed with foreign bytes. tryPrepareSetBloomFilter
type-gated on the set's element types and then tokenized raw bytes. A literal
tuple set is type-checked by PreparedSets::findTuple and declines, but
findSubquery takes no types, so a String-typed subquery set cleared the gate
and built ASCII probes against 16-byte granules. Measured with the round-0 fix
already present: ip IN ('2001:db8::') gave 1/1 while
ip IN (SELECT '2001:db8::') gave oracle 1 / indexed 0, Granules: 0/64. Same
wrong-results class as the headline bug and inside this change's own changelog
scope, so being pre-existing did not excuse it. Fixed by reusing the same
conversion per set element; one unconvertible element declines the whole atom,
since a partial set would under-approximate membership.

⚠️ The notEquals regression row was a blind oracle: it asserted only
(63, 63), which BoolMask::operator! makes invariant to the patched arm. I
rejected the automated pass's proposal to delete the notEquals conversion
instead: an atom that is unobservable alone is not unobservable in composition.
Strengthening the oracle instead surfaced the real mechanism (below).

Round 2 — 1 finding from my own review, which the automated pass missed

❌ The conversion hint described the compared value while the value being
converted was the map key. The map-subcolumn branch substitutes the serialized
key for the compared value but left the type hint pointing at the right-hand
side, and convertFieldToType dispatches on that hint: a FixedString(16)
source is binary-decoded as an address. On Map(IPv6, ...) with a mapKeys
index and an explicitly typed compared value, a 16-byte key text was decoded
into the wrong bytes, reproducing the exact defect this change claims to fix on
that carrier, and any other key length hit EOF and silently lost a legitimate
prune. Fixed by letting a source type travel with the substituted value. The
mapValues branch correctly keeps the right-hand side's type because it does
not substitute; the latent arrayElement branch had already captured the right
type and discarded it.

Round 2 also closed round 1's second finding. The implementer escalated
rather than guess, and was right to: reverting only the notEquals conversion
left the whole reference byte-identical, while reverting only equals reddened
the row. Skip-index conditions are built from an inversion-pushed-down DAG, and
cloneDAGWithInversionPushDown renames the comparison through
inverse_relations instead of emitting a not, so NOT (ip != lit) arrives as
an equals atom and no spelling routes a notEquals probe into a deciding
position (18 spellings measured). I kept the conversion anyway: the
bloom_filter index converts both directions through one helper and is this
change's cited precedent, the call site is measurably behaviour-neutral today,
and reverting would leave a known-wrong probe guarded only by an accident of
BoolMask. That it is unobservable by any test is stated in the validation
gate rather than glossed over.

💡 Noted, not blocking: three open pull requests now queue on this file
(#112868, #112785, #113022). All are logically independent of this change and
conflict only textually, so whichever lands later re-integrates by hand.

Round 3 — clean. I enumerated the reachable constructions independently
before comparing against the plan and found no divergence. Verified against
source rather than accepted: byte-encoding equivalence on both the aggregator
and probe sides including the LowCardinality path; that the
String/FixedString early return is load-bearing, not an optimization; that
declining is always sound because an unresolved atom evaluates to
keep-the-granule; that the unconverted has/hasAny/hasAll arm is
unreachable because no common supertype exists between String and IPv6; that
the set path uses the element types rather than the key types; that no new throw
is introduced and no on-disk format changes; and that setting randomization
cannot neuter the granule assertions, because EXPLAIN force-disables the two
randomized settings that would matter and the rest only pick the filtering
algorithm.

Gate spend across all rounds: $22.25.

@groeneai

groeneai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, 100% on demand, no randomization or timing dependency: clickhouse local running the new test's SQL. Confirmed on three independent binaries (pristine master snapshot, a pre-edit build of this tree, and the fixed build).
b Root cause explained? The aggregator tokenizes the indexed column's raw bytes (16 bytes 20010DB800... for IPv6), while traverseTreeEquals gated on the constant's type and tokenized the constant's own bytes (ASCII 2001:db8::). The two encodings share no prefix, so contains() was false and the matching granule was pruned. The query is valid only because the comparison function converts separately at runtime; index analysis had no conversion step (grep -c convertFieldToType on this file was 0).
c Fix matches root cause? Yes. It adds the missing conversion in the function that builds the probe, mirroring the bloom_filter sibling, which already converts and is correct on this query. Not a widened bound, not a no-random-* tag, not a guard at the pruning site. The comparison path is already correct and is untouched.
d Test intent preserved / new tests added? No existing test modified or weakened. One new stateless test with a Log twin oracle, EXPLAIN indexes = 1 granule assertions, a selectivity arm, an unreachable-arm arm, and a String/FixedString no-regression arm. Parallel-safe: no no-parallel, implicit database, no on-disk files.
e Both directions demonstrated? Yes. 26 assertions fail on the pristine master snapshot and on a pre-edit build of this tree, and pass with the fix. Build IDs recorded per arm and verified to differ. Five mutations each redden a distinct assertion group, so no group is vacuous: reverting the whole change reddens groups A, C and F; passing the target type as from_type_hint (a silent no-op) reddens group B only, since the atom then always declines; deleting the String/FixedString early return reddens the FixedString oversize row only; reverting just the set path reddens 5 group F rows and nothing else; making the set conversion decline unconditionally reddens only the group F arm that asserts a non-matching subquery value still prunes.
f Fix is general across code paths? Yes. All three reachable arms of this condition class call the one helper: equals and notEquals in traverseTreeEquals, plus tryPrepareSetBloomFilter, which had the same defect for a set built from a subquery (a literal tuple set is type-checked by PreparedSets::findTuple and declines, but findSubquery takes no types, so a String set reached the raw-byte tokenizer). Siblings enumerated: MergeTreeIndexBloomFilter.cpp already converts at five sites and is the model; MergeTreeIndexText.cpp and MergeTreeIndexConditionText.cpp never convert but their validator rejects IPv6 outright (verified live at DDL, Code: 36), so they are structurally immune rather than missed; KeyCondition already converts. Arms left alone are each verified unreachable for a non-String index domain and pinned by serverError assertions: has/hasAny/hasAll (Code: 386), all mapContains* (a String subcolumn is required), and like/match/hasToken*/startsWith/endsWith/multiSearchAny (Code: 43). Fixed at the probe construction, not at the pruning decision. One stated gap: the notEquals arm's conversion cannot be observed by any test, because skip-index conditions are built from an inversion-pushed-down DAG whose inverse_relations renames notEquals to equals under a NOT, so no spelling routes a notEquals probe into a position where it decides pruning (18 spellings measured, byte-identical with and without that call site). It is kept for consistency with its sibling and is fail-closed, but it rests on code reading rather than on a red-green mutation. The conversion is also given the source type of the value it actually converts: the map-key paths substitute the map key for the compared value, so a local source type travels with the value instead of describing the comparison's right-hand side. Without that, a FixedString(16) right-hand side made convertFieldToType binary-decode a 16-byte map-key text as an address (the same foreign-byte probe) and made any other key length decline and lose a legitimate prune. The mapValues sibling keeps the right-hand side's type because it does not substitute the value, and the latent arrayElement/mapKeys branch is fixed for consistency although it is unreachable for an IPv6-keyed map (ILLEGAL_TYPE_OF_ARGUMENT, re-measured).
g Fix generalizes across inputs (params/datatypes/wrappers)? Yes, matrix measured. Column wrappers: IPv6 and LowCardinality(IPv6) both tested (getPrimitiveType strips the wrapper); Nullable(IPv6), LowCardinality(Nullable(IPv6)), Array(Nullable(IPv6)), IPv4 and UUID are rejected at DDL. Index types: ngrambf_v1, tokenbf_v1, sparse_grams. Containers: Array(IPv6), Map(IPv6, String) via mapKeys including the map-key subcolumn form, Map(String, IPv6) via mapValues, plus a Map(String, String) and Array(String) regression check. Spellings: compressed, fully expanded, uppercase, ::1, ::, and the v4-mapped and bare-v4 pair that collapse to one value. Boundaries: all-zero default, unparseable literal, and a FixedString constant shorter than, equal to and wider than the column width. For the set arm: a single-element subquery set, a multi-element set, a non-matching element (which must still prune), and NOT IN; if any element cannot be represented the whole atom declines, which is the fail-closed boundary. Compared-value types on the map-key paths: a bare literal (a String, which coincides with the key's own type), a FixedString(16) (which is what a binary address decode is keyed on), and a FixedString(8) (not address width, so unaffected); each is paired with a 16-byte and a non-16-byte key text, and the key-text lengths are asserted with length() in the test rather than counted by eye. Two carriers were outside the original scope and are broken the same way on master and fixed by the same change: the map-key subcolumn form, and IN with a subquery.
h Backward compatible? Yes. No new setting, so no SettingsChangesHistory.cpp entry is needed. No serialization or on-disk format change: granule bytes are untouched and only the probe changes, so existing indexes remain valid and do not need rebuilding, because the aggregator was always right and the query side was wrong. Strictly more rows are returned where rows were previously dropped; no query that worked before now fails.
i Invariants and contracts preserved? The probe-encoding invariant (probe bytes must come from the same encoding the aggregator tokenized) is now enforced rather than assumed. Fail-closed on every path: an empty Field from tryConvertFieldToType and a false return from tryInsert both decline the atom, which yields a full scan and a correct answer; neither throws. No new throw is introduced, and the pre-existing CANNOT_PARSE_IPV6 from the comparison function is unchanged (verified in three shapes: primary key pruned, not pruned, and on the Log twin). I grepped the suite: no test asserts that error from an index-analysis path. Zero behavior change on the String/FixedString domain, guaranteed by the early return and asserted by the no-regression arm plus its mutation. Analysis-time only: one conversion and one single-row column per constant per index, replacing one safeGet<String>; no per-row cost.

Session id: cron:clickhouse-review-slot-50:20260803-152300

@groeneai

groeneai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

cc @al13n321 @rschu1ze, could you review this? Index analysis for the ngrambf_v1/tokenbf_v1/sparse_grams condition never converted the constant into the indexed column's type, so on an IPv6 column a string literal was tokenized as ASCII while the granule holds the 16 raw bytes, and the matching granule was pruned; the fix adds the conversion the bloom_filter sibling already does.

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

clickhouse-gh Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [9f6a640]

Summary:


AI Review

Summary

This PR fixes wrong-result pruning in ngrambf_v1, tokenbf_v1, and sparse_grams over IPv6 data by rebuilding text-index probes from the indexed column's byte encoding instead of raw string-literal bytes, and it carries the same fix through subquery-backed IN sets and the mapKeys/mapValues equality paths. I reviewed the current diff against the surrounding implementation, checked the prior PR discussion, and spot-checked the current Praktika report; I did not find any remaining correctness, compatibility, or evidence gaps that rise to a review finding.

Final Verdict

✅ No new blockers or majors.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.60% -0.10%

Changed lines: Changed C/C++ lines covered: 53/58 (91.38%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 3, 2026
@groeneai

groeneai commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

CI finish ledger - 9f6a640

No failures to own on this commit: 157 success, 18 skipped, 0 failures out of 175 check-runs.
Finish Workflow succeeded, and no check is queued or in progress.

Session id: cron:our-pr-ci-monitor:20260804-000000

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.

2 participants