arunkumarucet opened a new pull request, #19865:
URL: https://github.com/apache/pinot/pull/19865
## Problem
A text query is parsed and analyzed independently inside every segment's
`LuceneTextIndexReader`, so a table with N segments parses the same search
string **N times per query**. The parse depends only on the search string and
the column's text-index configuration — never on segment contents — so all but
one of those parses is duplicate work.
Profiling a `TEXT_MATCH` count on a 500-segment table with async-profiler
attributed **~27% of server CPU** to `QueryParserBase.parse` and the analyzer
it drives, every sample under `LuceneTextIndexReader#buildQuery`:
```
TextMatchFilterOperator.getNumMatchingDocs
LuceneTextIndexReader.getNumMatchingDocs
LuceneTextIndexReader.buildQuery
QueryParserBase.parse <- 18.5% self
... QueryBuilder.createFieldQuery -> analyzer <- 9.9% self
```
## Change
Cache the parsed `Query`, keyed by the search string, the options string,
and a **scope** that captures every setting which can change how the string
parses: column, analyzer class and its arguments, stop words, case sensitivity,
query parser class, `useANDForMultiTermQueries`, and
`enablePrefixSuffixMatchingInPhraseQueries`.
The scope matters because the cache is process-wide. Keying on the column
name alone would let two tables that share a column name (`Body`, `message`,
`text` are not rare) but parse differently serve each other's queries — e.g.
`StandardAnalyzer` keeps `send_email` as one token where `SimpleAnalyzer`
splits it, and flipping `useANDForMultiTermQueries` turns `a b` from OR into
AND. `testConfigDifferenceIsNotSharedAcrossReaders` pins this; it fails against
a column-only key.
Sharing a `Query` across readers is safe: Lucene queries are immutable and
`Query#rewrite` does not mutate them, so a multi-term query is still expanded
per segment against that segment's own term dictionary.
The cache is **bounded** — 5000 entries via Guava `maximumSize`. The keys
contain user-supplied search strings, whose cardinality is unbounded on an
ad-hoc search workload, and an eviction only costs a re-parse. (Guava's cache
is already the idiom in this module.)
## Measurements
SereneDB [SearchBench](https://github.com/serenedb/searchbench) `otel_logs`
corpus, **100M rows in 250 segments**, single node, p50 of 7 iterations,
compared against the faster of **two** stock runs (run-to-run noise: median
4.6%, p90 23.3%):
| Query | Filter | Stock | This PR | |
|---|---|---|---|---|
| Q16 | regexp `/charg.*/` | 13.20 ms | 5.52 ms | **2.39x** |
| Q21 | prefix `charg*` | 6.81 ms | 4.21 ms | **1.62x** |
| Q27 | like `%nnec%` | 19.12 ms | 12.11 ms | **1.58x** |
| Q17 | regexp `/ord.*/` | 13.66 ms | 8.97 ms | **1.52x** |
| Q18 | regexp `/conn.*/` | 10.50 ms | 7.24 ms | **1.45x** |
| Q01 | term `error` | 6.74 ms | 4.80 ms | **1.40x** |
| Q02 | term `payment` | 4.87 ms | 3.53 ms | **1.38x** |
| Q20 | prefix `conn*` | 7.60 ms | 5.64 ms | **1.35x** |
| Q26 | like `%tion` | 23.47 ms | 17.58 ms | **1.34x** |
| Q58 | group_by over regexp | 24.58 ms | 19.97 ms | **1.23x** |
Multi-term queries gain the most: building a `RegexpQuery`/`WildcardQuery`,
automaton included, is far more expensive than parsing a plain term, so
hoisting it out of the per-segment loop is worth more there.
Joins are unchanged — they are dominated by the multi-stage shuffle, where
one parse per segment is rounding error.
The saving is a fixed cost **per segment per query**, so it scales with
segment count rather than row count. Tables with few segments will see little;
the absolute milliseconds above carry over to larger tables with the same 250
segments, while the percentages shrink as each segment does more work.
## Correctness
All **63 deterministic** SearchBench queries returned byte-identical results
before and after. Six (`Q72`, `Q77`–`Q79`, `Q82`, `Q83`) differ, but they are
`LIMIT 100` without a total order and are non-deterministic on the *same* build
— `Q82` produced 3 distinct result hashes in 8 consecutive stock runs.
## Tests
New `LuceneTextIndexQueryCacheTest`:
- a repeated parse is served from the cache, and `parseQuery` still bypasses
it
- **two readers differing only in text-index config do not share an entry**,
and the difference remains visible in `getNumMatchingDocs` (verified to fail
against a column-only key)
- the options string is part of the key
`./mvnw -pl pinot-segment-local -Dtest='*Text*' test` is green (16 classes,
84 tests), as are `checkstyle:check` and `license:check`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]