Skip to content

fix(engine): match_phrase/match_phrase_prefix matched 0 on array fields - #46

Merged
xerj-org merged 2 commits into
xerj-org:mainfrom
Vinz2168:fix/match-phrase-array-values
Jul 27, 2026
Merged

fix(engine): match_phrase/match_phrase_prefix matched 0 on array fields#46
xerj-org merged 2 commits into
xerj-org:mainfrom
Vinz2168:fix/match-phrase-array-values

Conversation

@Vinz2168

Copy link
Copy Markdown
Collaborator

⚠️ Stacked on #33

This branch is cut from fix/boolean-numeric-column-matching (open PR #33, not yet merged) because the MatchPhrase arm this fix touches is the same one #33 already modified (adding Bool/Number handling). 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_prefix never 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 send match_phrase, not term.

Root cause

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, whether the plain field or a .keyword multi-field sharing its value. MatchPhrasePrefix had the identical gap (only handled a scalar Value::String).

This is a companion, independently-necessary fix alongside the get_field_value multi-field fallback (#44): 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.

Test plan

  • cargo build --release -p xerj-engine -p xerj-api -p xerj-server
  • cargo fmt --check / cargo clippy --no-deps -- -D warnings — clean
  • cargo test --release -p xerj-engine --lib — 156/156 passed
  • 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
  • Full ES-compat YAML conformance suite: 1360 passed, 0 failed, 3 skipped — no regressions

🤖 Generated with Claude Code

Vincenzo Lombardo and others added 2 commits July 26, 2026 12:05
… 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
@xerj-org
xerj-org merged commit 84d11a1 into xerj-org:main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants