Jackie-Jiang commented on code in PR #19865:
URL: https://github.com/apache/pinot/pull/19865#discussion_r4288281436


##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexReader.java:
##########
@@ -297,8 +355,38 @@ public int getNumMatchingDocs(String searchQuery, 
@Nullable String optionsString
   /// 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.
+  /// Returns the parsed query, reusing the one already parsed for this 
request when a cache is given.
+  ///
+  /// Every segment of a request parses the same string against the same 
configuration, so without a
+  /// cache the work is repeated once per segment. The cache computes a key at 
most once, so a cold
+  /// query parses once for the whole request rather than racing across 
segment threads.
+  ///
+  /// 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.getOrCompute(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)

Review Comment:
   Nonblocking cleanup: clarify the parser helper names
   
   The call sequence is now `buildQuery(searchQuery, options, cache) -> 
parseQuery(searchQuery, options) -> buildQuery(searchQuery)`. Consider renaming 
the private one-argument `buildQuery` method to `parseQueryWithoutOptions` so 
the names make it clear which method handles caching and which performs parsing.



##########
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/text/LuceneTextIndexQueryCacheTest.java:
##########
@@ -0,0 +1,219 @@
+/**
+ * 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.QueryScopedCache;
+import org.apache.pinot.spi.config.table.FieldConfig;
+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;
+
+
+/// 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 getOrCompute(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 {
+      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()))) {
+            start.await();
+            return reader.buildQuery("connection", null, cache);
+          }
+        });
+      }
+      start.countDown();

Review Comment:
   Nonblocking cleanup: wait for worker readiness before opening the start gate
   
   The start latch is released immediately after task submission, while workers 
open their readers before reaching `start.await()`. A worker can therefore 
parse and populate the cache before other workers have finished initialization, 
so the test need not exercise concurrent misses. Add a readiness latch that 
each worker decrements after opening its reader, wait for all workers with a 
bounded timeout, then release the start latch. This would more reliably 
exercise concurrent access without relying on reader initialization timing.



##########
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/QueryScopedCache.java:
##########
@@ -0,0 +1,41 @@
+/**
+ * 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 compute a given key at most once 
and be safe for

Review Comment:
   Nonblocking cleanup: state the precise computation guarantee
   
   Please describe atomic computation per `(type, key)` and clarify that failed 
or null computations are not retained, matching 
`ConcurrentHashMap.computeIfAbsent`. A mapper can run again after throwing or 
returning null, so the unconditional "compute a given key at most once" wording 
overstates the guarantee. The reader's corresponding comment should also say 
once per effective parsing configuration, since one request can encounter 
segments with different configurations.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java:
##########
@@ -620,6 +621,11 @@ public int 
getSortAggregateSequentialCombineNumSegmentsThreshold() {
   /// @param <K> the key type
   /// @param <V> the value type
   /// @return the shared value
+  @Override
+  public <K, V> V getOrCompute(Class<V> type, K key, Function<K, V> mapper) {

Review Comment:
   Nonblocking cleanup: reuse the existing cache method name
   
   Could `QueryScopedCache` declare `getOrComputeSharedValue` instead of 
`getOrCompute`? The existing `QueryContext` method could then implement the 
interface directly with `@Override`, removing this forwarding method and 
keeping its documentation attached to the established API. The small SPI 
interface itself is useful for preserving module layering.



##########
pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/TextIndexReader.java:
##########
@@ -61,6 +61,23 @@ default int getNumMatchingDocs(String searchQuery, @Nullable 
String optionsStrin
     return getDocIds(searchQuery, optionsString).getCardinality();
   }
 
+  /// 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.
+  default MutableRoaringBitmap getDocIds(String searchQuery, @Nullable String 
optionsString,

Review Comment:
   [P2] Avoid ambiguous overloads for nullable multi-column calls
   
   `MultiColumnTextIndexReader` already exposes `getDocIds(String column, 
String searchQuery, @Nullable String optionsString)` and the corresponding 
count method. Adding `(String, String, QueryScopedCache)` overloads makes 
previously valid calls such as `reader.getDocIds("body", "error", null)` and 
`reader.getNumMatchingDocs("body", "error", null)` ambiguous. I compiled the 
same small caller against the old and new interfaces: it succeeds before this 
change and fails with two ambiguous-method errors afterward. Existing binaries 
remain compatible; this affects callers when recompiled. Please put the cache 
parameter first or give these methods distinct names so the documented 
null-options calls continue to compile.



-- 
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]

Reply via email to