This is an automated email from the ASF dual-hosted git repository.

Jackie-Jiang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new ae34ff9c5a7 Cache parsed Lucene queries across segment readers (#19865)
ae34ff9c5a7 is described below

commit ae34ff9c5a7f994e876f6dd269a78f4110939ef0
Author: Arunkumar Saravanan <[email protected]>
AuthorDate: Sat Oct 17 03:08:21 2026 +0530

    Cache parsed Lucene queries across segment readers (#19865)
---
 .../operator/filter/TextMatchFilterOperator.java   |  42 ++--
 .../org/apache/pinot/core/plan/FilterPlanNode.java |   6 +-
 .../core/query/request/context/QueryContext.java   |  12 +-
 .../index/readers/text/LuceneTextIndexReader.java  | 104 +++++++-
 .../readers/text/LuceneTextIndexCountTest.java     |   2 +-
 .../text/LuceneTextIndexQueryCacheTest.java        | 264 +++++++++++++++++++++
 .../segment/spi/index/reader/QueryScopedCache.java |  47 ++++
 .../segment/spi/index/reader/TextIndexReader.java  |  19 ++
 8 files changed, 463 insertions(+), 33 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/TextMatchFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/TextMatchFilterOperator.java
index 59e6a88ce21..e971b7bf9b6 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/TextMatchFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/TextMatchFilterOperator.java
@@ -20,12 +20,14 @@ package org.apache.pinot.core.operator.filter;
 
 import com.google.common.base.CaseFormat;
 import java.util.List;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.request.context.predicate.TextMatchPredicate;
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.common.Operator;
 import org.apache.pinot.core.operator.ExplainAttributeBuilder;
 import org.apache.pinot.core.operator.docidsets.BitmapDocIdSet;
 import org.apache.pinot.segment.spi.index.reader.MultiColumnTextIndexReader;
+import org.apache.pinot.segment.spi.index.reader.QueryScopedCache;
 import org.apache.pinot.segment.spi.index.reader.TextIndexReader;
 import org.apache.pinot.spi.trace.FilterType;
 import org.apache.pinot.spi.trace.InvocationRecording;
@@ -42,13 +44,22 @@ public class TextMatchFilterOperator extends 
BaseFilterOperator {
   private final String _column;
   private final TextIndexReader _textIndexReader;
   private final TextMatchPredicate _predicate;
+  /// Lets the segments of one request share a parsed query instead of each 
parsing it again. Null
+  /// when no query scope is available, which simply means no sharing.
+  private final QueryScopedCache _queryScopedCache;
 
   public TextMatchFilterOperator(String column, TextIndexReader 
textIndexReader, TextMatchPredicate predicate,
       int numDocs) {
+    this(column, textIndexReader, predicate, numDocs, null);
+  }
+
+  public TextMatchFilterOperator(String column, TextIndexReader 
textIndexReader, TextMatchPredicate predicate,
+      int numDocs, @Nullable QueryScopedCache queryScopedCache) {
     super(getSearchableDocCount(textIndexReader, numDocs), false);
     _column = column;
     _textIndexReader = textIndexReader;
     _predicate = predicate;
+    _queryScopedCache = queryScopedCache;
   }
 
   private static int getSearchableDocCount(TextIndexReader reader, int 
numDocs) {
@@ -57,19 +68,17 @@ public class TextMatchFilterOperator extends 
BaseFilterOperator {
   }
 
   public TextMatchFilterOperator(TextIndexReader textIndexReader, 
TextMatchPredicate predicate, int numDocs) {
-    this(null, textIndexReader, predicate, numDocs);
+    this(null, textIndexReader, predicate, numDocs, null);
+  }
+
+  public TextMatchFilterOperator(TextIndexReader textIndexReader, 
TextMatchPredicate predicate, int numDocs,
+      @Nullable QueryScopedCache queryScopedCache) {
+    this(null, textIndexReader, predicate, numDocs, queryScopedCache);
   }
 
   @Override
   protected BlockDocIdSet getTrues() {
-    if (_textIndexReader.isMultiColumn()) {
-      return BitmapDocIdSet.create(
-          ((MultiColumnTextIndexReader) _textIndexReader).getDocIds(_column, 
_predicate.getValue(),
-              _predicate.getOptions()), _numDocs);
-    } else {
-      return 
BitmapDocIdSet.create(_textIndexReader.getDocIds(_predicate.getValue(), 
_predicate.getOptions()),
-          _numDocs);
-    }
+    return BitmapDocIdSet.create(getMatchingDocIds(), _numDocs);
   }
 
   @Override
@@ -85,7 +94,8 @@ public class TextMatchFilterOperator extends 
BaseFilterOperator {
     } else {
       // Ask the index for the size of the match set rather than for the match 
set itself: building a bitmap of
       // every matching doc just to read its cardinality is the dominant cost 
of a count over a frequent term.
-      return _textIndexReader.getNumMatchingDocs(_predicate.getValue(), 
_predicate.getOptions());
+      return _textIndexReader.getNumMatchingDocs(_predicate.getValue(), 
_predicate.getOptions(),
+          _queryScopedCache);
     }
   }
 
@@ -96,14 +106,18 @@ public class TextMatchFilterOperator extends 
BaseFilterOperator {
 
   @Override
   public BitmapCollection getBitmaps() {
-    ImmutableRoaringBitmap bitmap = _textIndexReader.isMultiColumn()
-        ? ((MultiColumnTextIndexReader) _textIndexReader).getDocIds(_column, 
_predicate.getValue(),
-        _predicate.getOptions()) : 
_textIndexReader.getDocIds(_predicate.getValue(), _predicate.getOptions());
-
+    ImmutableRoaringBitmap bitmap = getMatchingDocIds();
     record(bitmap);
     return new BitmapCollection(_numDocs, false, bitmap);
   }
 
+  private ImmutableRoaringBitmap getMatchingDocIds() {
+    return _textIndexReader.isMultiColumn()
+        ? ((MultiColumnTextIndexReader) _textIndexReader).getDocIds(_column, 
_predicate.getValue(),
+            _predicate.getOptions())
+        : _textIndexReader.getDocIds(_predicate.getValue(), 
_predicate.getOptions(), _queryScopedCache);
+  }
+
   @Override
   public List<Operator> getChildOperators() {
     return List.of();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
index 74bdb9f2a38..efbd73e8dfb 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
@@ -307,9 +307,11 @@ public class FilterPlanNode implements PlanNode {
                   "Cannot apply TEXT_MATCH on column: %s without text index", 
column);
 
               if (textIndexReader.isMultiColumn()) {
-                return new TextMatchFilterOperator(column, textIndexReader, 
(TextMatchPredicate) predicate, numDocs);
+                return new TextMatchFilterOperator(column, textIndexReader, 
(TextMatchPredicate) predicate, numDocs,
+                    _queryContext);
               } else {
-                return new TextMatchFilterOperator(textIndexReader, 
(TextMatchPredicate) predicate, numDocs);
+                return new TextMatchFilterOperator(textIndexReader, 
(TextMatchPredicate) predicate, numDocs,
+                    _queryContext);
               }
             case REGEXP_LIKE:
               // PredicateEvaluatorProvider handles FST/IFST upgrade 
internally when the dictionary is usable for
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
index dbbba9a8bb7..24548c551dd 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
@@ -41,6 +41,7 @@ import 
org.apache.pinot.core.query.aggregation.function.AggregationFunction;
 import 
org.apache.pinot.core.query.aggregation.function.AggregationFunctionFactory;
 import org.apache.pinot.core.util.GroupByUtils;
 import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.segment.spi.index.reader.QueryScopedCache;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.utils.CommonConstants;
@@ -64,7 +65,7 @@ import org.apache.pinot.spi.utils.CommonConstants.Server;
 /// - We can also add some helper variables or methods in the context classes 
which can be shared for all segments to
 ///   reduce the repetitive work for each segment.
 @SuppressWarnings({"rawtypes", "unchecked"})
-public class QueryContext {
+public class QueryContext implements QueryScopedCache {
   private final String _tableName;
   private final QueryContext _subquery;
   private final List<ExpressionContext> _selectExpressions;
@@ -612,14 +613,7 @@ public class QueryContext {
     return _sortAggregateSequentialCombineNumSegmentsThreshold;
   }
 
-  /// Gets or computes a value of type `V` associated with a key of type `K` 
so that it can be shared
-  /// within the scope of a query.
-  /// @param type the type of the value produced, guarantees type pollution is 
impossible.
-  /// @param key the key used to determine if the value has already been 
computed.
-  /// @param mapper A function to apply the first time a key is encountered to 
construct the value.
-  /// @param <K> the key type
-  /// @param <V> the value type
-  /// @return the shared value
+  @Override
   public <K, V> V getOrComputeSharedValue(Class<V> type, K key, Function<K, V> 
mapper) {
     return ((ConcurrentHashMap<K, V>) _sharedValues.computeIfAbsent(type, k -> 
new ConcurrentHashMap<>()))
         .computeIfAbsent(key, mapper);
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java
index 8fa450766b1..6e73e11f10d 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java
@@ -50,6 +50,7 @@ import 
org.apache.pinot.segment.local.utils.LuceneTextIndexUtils;
 import org.apache.pinot.segment.spi.V1Constants;
 import org.apache.pinot.segment.spi.index.TextIndexConfig;
 import org.apache.pinot.segment.spi.index.TextIndexConfig.DocIdTranslatorMode;
+import org.apache.pinot.segment.spi.index.reader.QueryScopedCache;
 import org.apache.pinot.segment.spi.index.reader.TextIndexReader;
 import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
 import org.apache.pinot.segment.spi.store.SegmentDirectoryPaths;
@@ -79,6 +80,9 @@ public class LuceneTextIndexReader implements TextIndexReader 
{
   private final String _queryParserClass;
   private Constructor<QueryParserBase> _queryParserClassConstructor;
   private boolean _enablePrefixSuffixMatchingInPhraseQueries = false;
+  /// Everything about this column that changes how a search string parses. 
Two readers may share a
+  /// parsed query only when these are equal. Computed once; immutable, with 
structural equality.
+  private final ParseConfig _parseConfig;
 
   public LuceneTextIndexReader(String column, File indexDir, int numDocs, 
TextIndexConfig config) {
     _column = column;
@@ -112,6 +116,8 @@ public class LuceneTextIndexReader implements 
TextIndexReader {
       _queryParserClass = config.getLuceneQueryParserClass();
       _queryParserClassConstructor =
           
TextIndexUtils.getQueryParserWithStringAndAnalyzerTypeConstructor(_queryParserClass);
+      // after the properties-file override above, so it reflects the 
configuration actually in force
+      _parseConfig = buildParseConfig(_column, config);
       LOGGER.info("Successfully read lucene index for {} from {}", _column, 
indexDir);
     } catch (Exception e) {
       LOGGER.error("Failed to instantiate Lucene text index reader for column 
{}, exception {}", column,
@@ -183,6 +189,8 @@ public class LuceneTextIndexReader implements 
TextIndexReader {
       _queryParserClass = config.getLuceneQueryParserClass();
       _queryParserClassConstructor =
           
TextIndexUtils.getQueryParserWithStringAndAnalyzerTypeConstructor(_queryParserClass);
+      // after the properties-file override above, so it reflects the 
configuration actually in force
+      _parseConfig = buildParseConfig(_column, config);
 
       LOGGER.info("Successfully read lucene index for {} from buffer", 
_column);
     } catch (Exception e) {
@@ -225,10 +233,16 @@ public class LuceneTextIndexReader implements 
TextIndexReader {
 
   @Override
   public MutableRoaringBitmap getDocIds(String searchQuery, @Nullable String 
optionsString) {
+    return getDocIds(searchQuery, optionsString, null);
+  }
+
+  @Override
+  public MutableRoaringBitmap getDocIds(String searchQuery, @Nullable String 
optionsString,
+      @Nullable QueryScopedCache queryScopedCache) {
     MutableRoaringBitmap docIds = new MutableRoaringBitmap();
     Collector docIDCollector = new LuceneDocIdCollector(docIds, 
_docIdTranslator);
     try {
-      _indexSearcher.search(buildQuery(searchQuery, optionsString), 
docIDCollector);
+      _indexSearcher.search(buildQuery(searchQuery, optionsString, 
queryScopedCache), docIDCollector);
       return docIds;
     } catch (QueryException e) {
       throw e;
@@ -243,11 +257,49 @@ public class LuceneTextIndexReader implements 
TextIndexReader {
     }
   }
 
+  /// Identifies a parsing configuration. A record so that equality is 
structural: comparing the
+  /// rendered text of these lists instead would conflate `["failed", 
"order"]` with
+  /// `["failed, order"]`, which are different stop-word sets that parse the 
same string differently.
+  private record ParseConfig(String column, String analyzerClass, List<String> 
analyzerArgs,
+                             List<String> analyzerArgTypes, List<String> 
stopWordsInclude,
+                             List<String> stopWordsExclude, boolean 
caseSensitive, String queryParserClass,
+                             boolean useANDForMultiTermQueries, boolean 
prefixSuffixMatchingInPhraseQueries) {
+    private ParseConfig {
+      analyzerArgs = copy(analyzerArgs);
+      analyzerArgTypes = copy(analyzerArgTypes);
+      stopWordsInclude = copy(stopWordsInclude);
+      stopWordsExclude = copy(stopWordsExclude);
+    }
+
+    private static List<String> copy(@Nullable List<String> values) {
+      return values == null ? List.of() : List.copyOf(values);
+    }
+  }
+
+  /// Cache key for one parsed query: the configuration plus the 
request-supplied text.
+  private record ParsedQueryKey(ParseConfig parseConfig, String searchQuery, 
@Nullable String optionsString) {
+  }
+
+  /// Carries a checked parse failure out of the cache's mapper so the 
original exception, and in
+  /// particular a [QueryException]'s error code, survives instead of being 
wrapped.
+  private static final class ParseFailure extends RuntimeException {
+    ParseFailure(Exception cause) {
+      super(cause);
+    }
+  }
+
+  private static ParseConfig buildParseConfig(String column, TextIndexConfig 
config) {
+    return new ParseConfig(column, config.getLuceneAnalyzerClass(), 
config.getLuceneAnalyzerClassArgs(),
+        config.getLuceneAnalyzerClassArgTypes(), config.getStopWordsInclude(), 
config.getStopWordsExclude(),
+        config.isCaseSensitive(), config.getLuceneQueryParserClass(), 
config.isUseANDForMultiTermQueries(),
+        config.isEnablePrefixSuffixMatchingInPhraseQueries());
+  }
+
   /// Parses a search string into a Lucene query, applying the configured 
parser and options.
   ///
   /// Shared by the doc-id and the count paths so that a `count(*)` and the 
filter it counts can never
   /// interpret the same query differently.
-  private Query buildQuery(String searchQuery)
+  private Query parseQueryWithoutOptions(String searchQuery)
       throws Exception {
     // Lucene query parsers are generally stateful and a new instance must be 
created per query.
     QueryParserBase parser = _queryParserClassConstructor.newInstance(_column, 
_analyzer);
@@ -269,6 +321,12 @@ public class LuceneTextIndexReader implements 
TextIndexReader {
 
   @Override
   public int getNumMatchingDocs(String searchQuery, @Nullable String 
optionsString) {
+    return getNumMatchingDocs(searchQuery, optionsString, null);
+  }
+
+  @Override
+  public int getNumMatchingDocs(String searchQuery, @Nullable String 
optionsString,
+      @Nullable QueryScopedCache queryScopedCache) {
     try {
       // A Lucene index larger than the segment means the doc-id mapping is 
broken -- a state getDocIds
       // surfaces as an IndexOutOfBoundsException from the translator. 
Counting does not consult the
@@ -277,10 +335,10 @@ public class LuceneTextIndexReader implements 
TextIndexReader {
       // `context.docBase + doc`, which is maxDoc() space, and with deletions 
numDocs() < maxDoc() would let a
       // broken index slip through. Fall back so that it keeps failing loudly.
       if (_indexReader.maxDoc() > _numDocs) {
-        return getDocIds(searchQuery, optionsString).getCardinality();
+        return getDocIds(searchQuery, optionsString, 
queryScopedCache).getCardinality();
       }
       return LuceneTextIndexUtils.countWithoutMaterializing(_indexSearcher,
-          buildQuery(searchQuery, optionsString));
+          buildQuery(searchQuery, optionsString, queryScopedCache));
     } catch (QueryException e) {
       // A timeout or accountant kill arrives as a QueryException; wrapping it 
would erase its error code,
       // since only QueryException keeps its code through 
BaseSingleBlockCombineOperator.
@@ -292,19 +350,51 @@ public class LuceneTextIndexReader implements 
TextIndexReader {
     }
   }
 
-  /// Parses a search string into a Lucene query, honouring the options string 
when one is given.
+  /// Parses a search string into a Lucene query, honouring the options string 
when one is given, and
+  /// reusing the query already parsed for this request when a cache is given.
   ///
   /// Both [#getDocIds(String, String)] and [#getNumMatchingDocs] go through 
this method, so a `count(*)` and
   /// the filter it counts cannot interpret the same query differently. Keep 
it that way: any new entry point
   /// that parses a search string itself reintroduces that divergence.
+  ///
+  /// Every segment of a request parses the same string against the same 
configuration, so without a
+  /// cache the work is repeated once per segment. With a cache the string is 
parsed once per effective
+  /// parsing configuration rather than once per segment: one request can span 
segments whose text
+  /// indexes are configured differently, and those keep their own entries. A 
parse that throws caches
+  /// nothing, so the next segment retries and fails the same way.
+  ///
+  /// The returned query may be shared: 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.
+  @VisibleForTesting
+  Query buildQuery(String searchQuery, @Nullable String optionsString, 
@Nullable QueryScopedCache queryScopedCache)
+      throws Exception {
+    if (queryScopedCache == null) {
+      return parseQuery(searchQuery, optionsString);
+    }
+    ParsedQueryKey key = new ParsedQueryKey(_parseConfig, searchQuery, 
optionsString);
+    try {
+      return queryScopedCache.getOrComputeSharedValue(Query.class, key, k -> {
+        try {
+          return parseQuery(k.searchQuery(), k.optionsString());
+        } catch (Exception e) {
+          throw new ParseFailure(e);
+        }
+      });
+    } catch (ParseFailure e) {
+      throw (Exception) e.getCause();
+    }
+  }
+
+  /// Parses unconditionally, bypassing any cache.
   @VisibleForTesting
-  Query buildQuery(String searchQuery, @Nullable String optionsString)
+  Query parseQuery(String searchQuery, @Nullable String optionsString)
       throws Exception {
     if (hasOptions(optionsString)) {
       LuceneTextIndexUtils.LuceneTextIndexOptions options = 
LuceneTextIndexUtils.createOptions(optionsString);
       return LuceneTextIndexUtils.createQueryParserWithOptions(searchQuery, 
options, _column, _analyzer);
     }
-    return buildQuery(searchQuery);
+    return parseQueryWithoutOptions(searchQuery);
   }
 
   /// Whether `optionsString` selects the options-driven parser, i.e. it is 
non-blank and parses to at least
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexCountTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexCountTest.java
index a6f4e144beb..92ba615cb22 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexCountTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexCountTest.java
@@ -280,7 +280,7 @@ public class LuceneTextIndexCountTest {
       assertTrue(indexReader.leaves().size() > 1, "fixture is not multi-leaf");
       IndexSearcher searcher = new IndexSearcher(indexReader);
       try (LuceneTextIndexReader reader = new LuceneTextIndexReader(COLUMN, 
INDEX_DIR, DOCS.length, Map.of())) {
-        Query parsed = reader.buildQuery(query, options);
+        Query parsed = reader.parseQuery(query, options);
         assertEquals(LuceneTextIndexUtils.countWithoutMaterializing(searcher, 
parsed), searcher.count(parsed),
             "multi-leaf count disagreed with IndexSearcher#count for query: " 
+ query);
       }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexQueryCacheTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexQueryCacheTest.java
new file mode 100644
index 00000000000..e41e977237e
--- /dev/null
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexQueryCacheTest.java
@@ -0,0 +1,264 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.segment.local.segment.index.readers.text;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import org.apache.commons.io.FileUtils;
+import org.apache.lucene.search.Query;
+import 
org.apache.pinot.segment.local.segment.creator.impl.text.LuceneTextIndexCreator;
+import 
org.apache.pinot.segment.local.segment.index.text.TextIndexConfigBuilder;
+import org.apache.pinot.segment.spi.index.TextIndexConfig;
+import org.apache.pinot.segment.spi.index.reader.MultiColumnTextIndexReader;
+import org.apache.pinot.segment.spi.index.reader.QueryScopedCache;
+import org.apache.pinot.spi.config.table.FieldConfig;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+
+
+/// Covers the query-scoped reuse of parsed queries in [LuceneTextIndexReader].
+public class LuceneTextIndexQueryCacheTest {
+  private static final File TEMP_DIR = new File(FileUtils.getTempDirectory(),
+      LuceneTextIndexQueryCacheTest.class.getSimpleName() + "-" + 
UUID.randomUUID());
+  private static final File INDEX_DIR = new File(TEMP_DIR, "index");
+  private static final String COLUMN = "querycachebody";
+
+  private static final String[] DOCS = {
+      "failed to place order for user alice",
+      "failed to charge card for user bob",
+      "connection refused by payment service",
+      "order confirmation email sent to carol"
+  };
+
+  /// Stands in for `QueryContext`, which lives in pinot-core and is not 
visible here. It uses the same
+  /// `computeIfAbsent` contract, and counts how many times a key is actually 
computed so a test can
+  /// assert that a parse happened once rather than once per caller.
+  private static final class CountingCache implements QueryScopedCache {
+    private final ConcurrentHashMap<Object, Object> _values = new 
ConcurrentHashMap<>();
+    private final AtomicInteger _computeCount = new AtomicInteger();
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public <K, V> V getOrComputeSharedValue(Class<V> type, K key, Function<K, 
V> mapper) {
+      return (V) _values.computeIfAbsent(key, k -> {
+        _computeCount.incrementAndGet();
+        return mapper.apply((K) k);
+      });
+    }
+
+    int computeCount() {
+      return _computeCount.get();
+    }
+  }
+
+  @BeforeClass
+  public void setUp()
+      throws IOException {
+    FileUtils.forceMkdir(INDEX_DIR);
+    TextIndexConfig config = new TextIndexConfigBuilder().build();
+    try (LuceneTextIndexCreator creator = new LuceneTextIndexCreator(COLUMN, 
INDEX_DIR, true, false, null, null,
+        config)) {
+      for (String doc : DOCS) {
+        creator.add(doc);
+      }
+      creator.seal();
+    }
+  }
+
+  @AfterClass
+  public void tearDown() {
+    FileUtils.deleteQuietly(TEMP_DIR);
+  }
+
+  private static TextIndexConfig configWithProperties(Map<String, String> 
properties) {
+    return (TextIndexConfig) new 
TextIndexConfigBuilder().withProperties(properties).build();
+  }
+
+  private static TextIndexConfig configWithStopWords(List<String> 
stopWordsInclude) {
+    TextIndexConfig.AbstractBuilder builder = new TextIndexConfigBuilder();
+    builder.withStopWordsInclude(stopWordsInclude);
+    return (TextIndexConfig) builder.build();
+  }
+
+  private LuceneTextIndexReader reader(TextIndexConfig config) {
+    return new LuceneTextIndexReader(COLUMN, INDEX_DIR, DOCS.length, config);
+  }
+
+  @Test
+  public void testParseIsSharedWithinOneQuery()
+      throws Exception {
+    CountingCache cache = new CountingCache();
+    try (LuceneTextIndexReader first = reader(configWithProperties(Map.of()));
+        LuceneTextIndexReader second = reader(configWithProperties(Map.of()))) 
{
+      Query a = first.buildQuery("order", null, cache);
+      Query b = second.buildQuery("order", null, cache);
+      assertSame(b, a, "equivalent readers in one query should share the 
parsed query");
+      assertEquals(cache.computeCount(), 1, "the query should have been parsed 
exactly once");
+      assertNotSame(first.parseQuery("order", null), a, "parseQuery must 
bypass the cache");
+    }
+  }
+
+  @Test
+  public void testNoCacheMeansNoSharing()
+      throws Exception {
+    try (LuceneTextIndexReader reader = 
reader(configWithProperties(Map.of()))) {
+      Query a = reader.buildQuery("order", null, null);
+      Query b = reader.buildQuery("order", null, null);
+      assertNotSame(b, a);
+      assertEquals(b, a);
+      assertEquals(reader.getNumMatchingDocs("order", null, null), 2);
+    }
+  }
+
+  /// Regression test for a flattened cache key. `["failed", "order"]` and 
`["failed, order"]` are
+  /// different stop-word sets -- the first makes `failed` a stop word, the 
second does not -- yet both
+  /// render as `[failed, order]`. A key built from rendered text would let 
the second configuration
+  /// reuse the first's empty query and silently lose matches.
+  @Test
+  public void testStopWordListsThatRenderIdenticallyDoNotCollide()
+      throws Exception {
+    CountingCache cache = new CountingCache();
+    try (LuceneTextIndexReader twoStopWords = 
reader(configWithStopWords(List.of("failed", "order")));
+        LuceneTextIndexReader oneStopWord = 
reader(configWithStopWords(List.of("failed, order")))) {
+      Query withFailedAsStopWord = twoStopWords.buildQuery("failed", null, 
cache);
+      Query withFailedAsTerm = oneStopWord.buildQuery("failed", null, cache);
+      assertNotEquals(withFailedAsTerm, withFailedAsStopWord,
+          "configurations whose stop-word lists only render the same must not 
share a cache entry");
+      assertEquals(cache.computeCount(), 2, "each configuration must be parsed 
on its own");
+      assertEquals(withFailedAsStopWord, twoStopWords.parseQuery("failed", 
null));
+      assertEquals(withFailedAsTerm, oneStopWord.parseQuery("failed", null));
+    }
+  }
+
+  @Test
+  public void testMultiTermConfigDoesNotCollide()
+      throws Exception {
+    CountingCache cache = new CountingCache();
+    try (LuceneTextIndexReader orReader = reader(
+        
configWithProperties(Map.of(FieldConfig.TEXT_INDEX_USE_AND_FOR_MULTI_TERM_QUERIES,
 "false")));
+        LuceneTextIndexReader andReader = reader(
+            
configWithProperties(Map.of(FieldConfig.TEXT_INDEX_USE_AND_FOR_MULTI_TERM_QUERIES,
 "true")))) {
+      assertNotEquals(andReader.buildQuery("failed order", null, cache),
+          orReader.buildQuery("failed order", null, cache));
+      assertEquals(orReader.getNumMatchingDocs("failed order", null, cache), 
3);
+      assertEquals(andReader.getNumMatchingDocs("failed order", null, cache), 
1);
+    }
+  }
+
+  @Test
+  public void testOptionsStringIsPartOfTheKey()
+      throws Exception {
+    CountingCache cache = new CountingCache();
+    try (LuceneTextIndexReader reader = 
reader(configWithProperties(Map.of()))) {
+      assertNotEquals(reader.buildQuery("failed order", 
"parser=MATCH,operator=AND", cache),
+          reader.buildQuery("failed order", null, cache));
+      assertEquals(cache.computeCount(), 2);
+    }
+  }
+
+  /// A cold query hit by every segment thread at once must still be parsed 
once, not once per thread.
+  @Test
+  public void testConcurrentMissesParseOnce()
+      throws Exception {
+    int threads = 16;
+    CountingCache cache = new CountingCache();
+    ExecutorService executor = Executors.newFixedThreadPool(threads);
+    try {
+      // Opening a reader is far more expensive than the parse under test. 
Releasing the start gate
+      // straight after submission would let an early worker reach the cache 
while others were still
+      // in their constructor, so the test could pass without ever racing. 
Gate on readiness instead,
+      // so every worker is one call away from the cache when the gate opens.
+      CountDownLatch ready = new CountDownLatch(threads);
+      CountDownLatch start = new CountDownLatch(1);
+      Future<Query>[] futures = new Future[threads];
+      for (int i = 0; i < threads; i++) {
+        futures[i] = executor.submit(() -> {
+          try (LuceneTextIndexReader reader = 
reader(configWithProperties(Map.of()))) {
+            ready.countDown();
+            start.await();
+            return reader.buildQuery("connection", null, cache);
+          }
+        });
+      }
+      assertTrue(ready.await(60, TimeUnit.SECONDS), "all workers should open 
their readers");
+      start.countDown();
+      Query first = futures[0].get(60, TimeUnit.SECONDS);
+      for (Future<Query> future : futures) {
+        assertSame(future.get(60, TimeUnit.SECONDS), first, "all threads 
should observe one parsed query");
+      }
+      assertEquals(cache.computeCount(), 1, "a cold key must be parsed once, 
not once per thread");
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  /// Guards overload resolution for [MultiColumnTextIndexReader].
+  ///
+  /// Cast null options to String to select the column-aware overloads instead 
of the overloads
+  /// ending in a query-scoped cache.
+  @Test
+  public void testMultiColumnNullOptionsWithExplicitCast() {
+    MultiColumnTextIndexReader reader = multiColumnReaderReturning(new 
MutableRoaringBitmap());
+    assertEquals(reader.getDocIds("body", "error", (String) 
null).getCardinality(), 0);
+    assertEquals(reader.getNumMatchingDocs("body", "error", (String) null), 0);
+  }
+
+  private static MultiColumnTextIndexReader 
multiColumnReaderReturning(MutableRoaringBitmap docIds) {
+    return new MultiColumnTextIndexReader() {
+      @Override
+      public MutableRoaringBitmap getDocIds(String column, String searchQuery, 
String optionsString) {
+        return docIds;
+      }
+
+      @Override
+      public MutableRoaringBitmap getDocIds(String searchQuery) {
+        return docIds;
+      }
+
+      @Override
+      public ImmutableRoaringBitmap getDictIds(String searchQuery) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void close() {
+      }
+    };
+  }
+}
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/QueryScopedCache.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/QueryScopedCache.java
new file mode 100644
index 00000000000..7419d70bcd6
--- /dev/null
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/QueryScopedCache.java
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.segment.spi.index.reader;
+
+import java.util.function.Function;
+
+
+/// A cache whose lifetime is a single query, letting the segments of one 
request share a value that
+/// is expensive to derive and identical for all of them.
+///
+/// Index readers live longer than a query and are shared by every query, so 
they cannot hold
+/// per-query state themselves; the caller owns the cache and passes it in.
+///
+/// This interface exists so that segment-level code can use a query-scoped 
cache without depending
+/// on the query engine. Implementations must be safe for concurrent use, 
because the segments of
+/// one request are processed in parallel.
+public interface QueryScopedCache {
+
+  /// Returns the value for `key`, computing it with `mapper` if it is not 
already present.
+  ///
+  /// Computation is atomic per `(type, key)`: concurrent callers that miss 
the same key block until
+  /// one `mapper` completes, and all of them observe that single result. A 
`mapper` that throws or
+  /// returns `null` leaves nothing cached, so a later call for the same key 
runs it again. These are
+  /// the semantics of 
[java.util.concurrent.ConcurrentHashMap#computeIfAbsent]; implementations
+  /// backed by it satisfy this contract.
+  ///
+  /// @param type the type of the value, which keeps unrelated callers from 
colliding on equal keys
+  /// @param key the key identifying the value; must be immutable with 
structural equality
+  /// @param mapper computes the value the first time the key is seen
+  <K, V> V getOrComputeSharedValue(Class<V> type, K key, Function<K, V> 
mapper);
+}
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/TextIndexReader.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/TextIndexReader.java
index 79f33ac6797..3230ff4b008 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/TextIndexReader.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/TextIndexReader.java
@@ -43,6 +43,19 @@ public interface TextIndexReader extends IndexReader {
     return getDocIds(searchQuery);
   }
 
+  /// Returns the matching document ids, reusing a query-scoped parse when the 
implementation supports it.
+  ///
+  /// Parsing a search string depends only on the string and the column's 
index configuration, so every
+  /// segment of one request would otherwise repeat identical work. 
Implementations that parse should
+  /// derive their cache key from the search string, the options and the 
configuration that affects
+  /// parsing. A `null` cache means "do not share", and must behave exactly 
like the two-argument form.
+  ///
+  /// Only the *parsed query* is shared. Doc ids are per-segment and must 
never be cached across readers.
+  default MutableRoaringBitmap getDocIds(String searchQuery, @Nullable String 
optionsString,
+      @Nullable QueryScopedCache queryScopedCache) {
+    return getDocIds(searchQuery, optionsString);
+  }
+
   /// Returns the number of documents matching the given search query, without 
materializing the matching
   /// document ids.
   ///
@@ -61,6 +74,12 @@ public interface TextIndexReader extends IndexReader {
     return getDocIds(searchQuery, optionsString).getCardinality();
   }
 
+  /// Counting counterpart of [#getDocIds(String, String, QueryScopedCache)].
+  default int getNumMatchingDocs(String searchQuery, @Nullable String 
optionsString,
+      @Nullable QueryScopedCache queryScopedCache) {
+    return getNumMatchingDocs(searchQuery, optionsString);
+  }
+
   /// Marker method that allows to differentiate between single-column and 
multi-column text index reader .
   default boolean isMultiColumn() {
     return false;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to