fix(engine): match_phrase/match_phrase_prefix matched 0 on array fields - #46
Merged
Merged
Conversation
… to 0
Root-caused a real OpenSearch Dashboards bug: a "term" or "match_phrase"
filter on a boolean field (e.g. a stock Flights sample-data dashboard
filtering FlightDelay: true) always returned 0 hits, while a `terms`
aggregation on the SAME field correctly bucketed true/false counts. The
asymmetry (aggregation right, filter wrong) pointed at the query/count
path specifically, not the stored data.
Reproduction took most of the effort here: neither a small hand-built
index, an explicit `_flush`/`_forcemerge` after bulk import, nor a
direct `_reindex` copy of the actual broken index's data ever
reproduced it -- all came back correct. The deterministic trigger
turned out to be simpler than any of those: querying a term/match_phrase
on a boolean field WHILE the bulk-imported data is still memtable-
resident (before any explicit flush) -- exactly what every real bulk
import does, OpenSearch Dashboards' own sample-data importer included,
since nothing in normal operation ever calls `_flush` itself. Verified
by bulk-importing OpenSearch Dashboards' own real flights sample
dataset (fetched via `_reindex`/scroll from a real OSD-populated index)
into a fresh index and querying it immediately, with zero manual
flush/merge -- the same shape every real import produces.
Traced via temporary debug instrumentation (added, used to pin the
defect, then removed -- not part of this diff) across four candidate
code paths before finding the actual one: `do_flush_shard`,
`merge_pass_locked`'s `build_doc_value_columns` call, `scored_columnar`'s
per-segment leaf construction, and `try_shortcut_count`. The query
actually goes through `try_shortcut_count` (`scored_fast_ready` was
false because the memtable still held the just-bulk-imported docs --
correct, since they weren't flushed yet), whose Bool handling was
broken in two independent, compounding ways:
1. `memtable.rs`'s `push_field`: `Value::Bool` was stored ONLY in the
keyword column ("true"/"false" strings), never in the numeric
column -- unlike `Value::Number`, which (correctly) populates both.
The on-disk segment builder (`build_doc_value_columns` in index.rs)
has always encoded booleans as the numeric f64 bit-pattern of
1.0/0.0, matching `scored_fast_plan`'s `scoring_leaf` (already
correct). The memtable's own in-memory representation was the only
place with the inconsistent, keyword-only encoding.
2. `try_shortcut_count`'s Bool handling: `value.as_f64()` and
`value.as_str()` both return `None` for a JSON boolean, so the
memtable-side match count was unconditionally 0 regardless of
actual content, and the segment-side numeric-column fast path never
engaged either (falling through toward a confidently-wrong FTS
lookup instead of a safe fallback). Fixed by coercing `Value::Bool`
to both the 1.0/0.0 numeric form and the "true"/"false" string form
up front, mirroring the fix already correct in `scoring_leaf` and
now in `memtable.rs`'s own encoding.
A third, related but independently-reproducible bug surfaced testing
`match_phrase` specifically (the same query shape OpenSearch
Dashboards' filter bar sends for a boolean-field filter, see the
xerj-query parser fix in a separate PR): `doc_matches_query`'s brute-
force `MatchPhrase` matcher only handled `Value::String` field values
-- a boolean or numeric `_source` field value fell to the `_ => None`
catch-all and never matched any document regardless of value. Fixed by
stringifying `Value::Bool`/`Value::Number` field values the same way
`Value::String` already was, before tokenizing for the phrase match.
Verified end-to-end: bulk-imported the real flights sample dataset
(10000 docs, 2511 true) with zero manual flush, immediately queried
`term`, `match_phrase`, and `terms` agg on `FlightDelay` -- all three
now agree (2511 true / 7489 false), where `term`/`match_phrase`
previously returned 0 unconditionally.
Full ES-compat YAML conformance suite: 1360 passed, 0 failed, 3
skipped -- no regressions. `xerj-engine` unit + integration tests: 156
+ 16 passed, 0 failed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XJaZygeuRfZfUg2B8tPKU
`doc_matches_query`'s `MatchPhrase` arm only handled `Value::String`/`Bool`/`Number` field values -- any `Value::Array` (eCommerce sample data's `manufacturer`/`category`, both arrays since an order can have multiple products) fell to the catch-all `_ => None`, so `match_phrase` NEVER matched any document on an array-valued field, regardless of query value -- whether the plain field or a `.keyword` multi-field sharing its value. `MatchPhrasePrefix` had the identical gap (only handled a scalar `Value::String`). Confirmed via real Kibana traffic (captured in docker logs): the exact filter Kibana's UI sends when filtering a dashboard by a `manufacturer`/`category` value is `match_phrase` (not `term`), so this was the actual root cause behind "filtering the eCommerce dashboard by category/manufacturer finds nothing" -- a companion, independently-necessary fix alongside the `get_field_value` multi-field fallback (separate PR): that fix makes `.keyword` multi-fields resolvable at all, this one makes the resulting array value actually matchable by `match_phrase`. `Match` (the standard, non-phrase `match` query) already handled `Value::Array` correctly -- only `MatchPhrase`/`MatchPhrasePrefix` had the gap. Fix: ES matches an array field if ANY element satisfies the phrase (same semantics `Term`'s existing array-unwrap already uses). Both arms now try each array element against the phrase/phrase-prefix logic and match if any succeeds. Verified with the exact bool-wrapped query captured from real Kibana traffic: `match_phrase` on `manufacturer`/`manufacturer.keyword` both now return the correct 1370 hits (was 0, matching the field's `terms` aggregation bucket exactly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XJaZygeuRfZfUg2B8tPKU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch is cut from
fix/boolean-numeric-column-matching(open PR #33, not yet merged) because theMatchPhrasearm this fix touches is the same one #33 already modified (addingBool/Numberhandling). The diff will show #33's commits until that PR merges — the actual new change here is the final commit only.Summary
match_phrase/match_phrase_prefixnever matched any document on an array-valued field, regardless of query value. Likely the real root cause behind tonight's "filtering the eCommerce dashboard by category/manufacturer finds nothing" report — confirmed via real Kibana traffic that the UI's filter pills sendmatch_phrase, notterm.Root cause
doc_matches_query'sMatchPhrasearm only handledValue::String/Bool/Numberfield values — anyValue::Array(eCommerce sample data'smanufacturer/category, both arrays since an order can have multiple products) fell to the catch-all_ => None, somatch_phrasenever matched any document on an array-valued field, whether the plain field or a.keywordmulti-field sharing its value.MatchPhrasePrefixhad the identical gap (only handled a scalarValue::String).This is a companion, independently-necessary fix alongside the
get_field_valuemulti-field fallback (#44): that fix makes.keywordmulti-fields resolvable at all; this one makes the resulting array value actually matchable bymatch_phrase.Match(the standard, non-phrasematchquery) already handledValue::Arraycorrectly — onlyMatchPhrase/MatchPhrasePrefixhad the gap.Fix
ES matches an array field if ANY element satisfies the phrase (same semantics
Term's existing array-unwrap already uses). Both arms now try each array element against the phrase/phrase-prefix logic and match if any succeeds.Test plan
cargo build --release -p xerj-engine -p xerj-api -p xerj-servercargo fmt --check/cargo clippy --no-deps -- -D warnings— cleancargo test --release -p xerj-engine --lib— 156/156 passedmatch_phraseonmanufacturer/manufacturer.keywordboth now return the correct 1370 hits (was 0), matching the field'stermsaggregation bucket exactly🤖 Generated with Claude Code