You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by dn...@apache.org on 2026/02/19 19:49:42 UTC
(lucene) branch branch_10x updated: Add hook for LRUQueryCache to allow custom population strategies
This is an automated email from the ASF dual-hosted git repository.
dnhatn pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/lucene.git
The following commit(s) were added to refs/heads/branch_10x by this push:
new c9284fdf907 Add hook for LRUQueryCache to allow custom population strategies
c9284fdf907 is described below
commit c9284fdf907224a9ea2dc9df4b4fd7299e904fda
Author: Nhat Nguyen <nh...@elastic.co>
AuthorDate: Thu Feb 19 11:49:36 2026 -0800
Add hook for LRUQueryCache to allow custom population strategies
In Elasticsearch, we execute intra-segment searches across multiple
threads, which can trigger redundant, concurrent cache populations for
the same segment. Since each slice-search targets a subset of the
segment but cache population scans the entire segment, this
amplification significantly wastes I/O and CPU for expensive queries.
This PR introduces a hook in LRUQueryCache to allow custom population
strategies, such as delegating to a background thread or skipping
redundant populations. There is no change to the default behavior of
LRUQueryCache.
Backport of #15723 to 10.x
---
lucene/CHANGES.txt | 2 +
.../org/apache/lucene/search/LRUQueryCache.java | 30 +++-
.../apache/lucene/search/TestLRUQueryCache.java | 197 +++++++++++++++++++++
3 files changed, 225 insertions(+), 4 deletions(-)
diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 1ead30be7ae..8f17cd907ba 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -13,6 +13,8 @@ API Changes
* GITHUB#15636: Introduce TermsEnum.preferSeekExact(). Update and delete processing obey this setting,
allowing bloom or other approximate membership filters to apply in these paths. (Trevor McCulloch)
+* GITHUB#15723: Add hook for LRUQueryCache to allow custom population strategies (Nhat Nguyen)
+
New Features
---------------------
(No changes)
diff --git a/lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java b/lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java
index 0add1c1bc5e..ff25393e850 100644
--- a/lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java
+++ b/lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java
@@ -290,7 +290,7 @@ public class LRUQueryCache implements QueryCache, Accountable {
}
}
- CacheAndCount get(Query key, IndexReader.CacheHelper cacheHelper) {
+ protected CacheAndCount get(Query key, IndexReader.CacheHelper cacheHelper) {
assert key instanceof BoostQuery == false;
assert key instanceof ConstantScoreQuery == false;
final IndexReader.CacheKey readerKey = cacheHelper.getKey();
@@ -533,6 +533,26 @@ public class LRUQueryCache implements QueryCache, Accountable {
}
}
+ /**
+ * Populates the cache for the given scorer and leaf reader context.
+ *
+ * <p>Subclasses can override this method to implement custom caching strategies, such as
+ * deferring cache population to a background thread or skipping it when other threads are already
+ * populating the cache. This can be useful for intra-segment searches.
+ *
+ * @return the cached result if available; otherwise {@code null}
+ */
+ protected CacheAndCount tryPopulateCache(
+ IndexReader.CacheHelper cacheKey,
+ Weight weight,
+ ScorerSupplier scorerSupplier,
+ LeafReaderContext context)
+ throws IOException {
+ final CacheAndCount cached = cacheImpl(scorerSupplier.bulkScorer(), context.reader().maxDoc());
+ putIfAbsent(weight.getQuery(), cached, cacheKey);
+ return cached;
+ }
+
private static CacheAndCount cacheIntoBitSet(BulkScorer scorer, int maxDoc) throws IOException {
final FixedBitSet bitSet = new FixedBitSet(maxDoc);
int[] count = new int[1];
@@ -824,9 +844,11 @@ public class LRUQueryCache implements QueryCache, Accountable {
if (cost / skipCacheFactor > leadCost) {
return supplier.get(leadCost).iterator();
}
-
- CacheAndCount cached = cacheImpl(supplier.bulkScorer(), maxDoc);
- putIfAbsent(in.getQuery(), cached, cacheHelper);
+ CacheAndCount cached = tryPopulateCache(cacheHelper, in, supplier, context);
+ // cache is not available, use uncached iterator
+ if (cached == null) {
+ return supplier.get(leadCost).iterator();
+ }
DocIdSetIterator disi = cached.iterator();
if (disi == null) {
// docIdSet.iterator() is allowed to return null when empty but we want a non-null
diff --git a/lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java b/lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java
index fc79ce6a4b1..1cf803a12be 100644
--- a/lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java
+++ b/lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java
@@ -19,6 +19,8 @@ package org.apache.lucene.search;
import static org.apache.lucene.util.RamUsageEstimator.HASHTABLE_RAM_BYTES_PER_ENTRY;
import static org.apache.lucene.util.RamUsageEstimator.LINKED_HASHTABLE_RAM_BYTES_PER_ENTRY;
import static org.apache.lucene.util.RamUsageEstimator.QUERY_DEFAULT_RAM_BYTES_USED;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.lessThanOrEqualTo;
import com.carrotsearch.randomizedtesting.generators.RandomNumbers;
import com.carrotsearch.randomizedtesting.generators.RandomPicks;
@@ -36,7 +38,9 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@@ -72,6 +76,7 @@ import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.RamUsageTester;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.Bits;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.IOUtils;
import org.apache.lucene.util.SuppressForbidden;
@@ -1548,6 +1553,198 @@ public class TestLRUQueryCache extends LuceneTestCase {
dir.close();
}
+ /**
+ * Tests that we can override {@link LRUQueryCache#tryPopulateCache} to skip caching when multiple
+ * threads concurrently attempt to cache the same segment for the same query.
+ */
+ public void testSkipCacheIfOngoing() throws Exception {
+ Directory dir = newDirectory();
+ IndexWriterConfig iwc = new IndexWriterConfig();
+ IndexWriter w = new IndexWriter(dir, iwc);
+ int numThreads = RandomNumbers.randomIntBetween(random(), 2, 6);
+ int numDocs = numThreads * 100;
+ for (int i = 0; i < numDocs; i++) {
+ var doc = new Document();
+ doc.add(new StringField("doc", Integer.toString(i), Store.NO));
+ w.addDocument(doc);
+ }
+ DirectoryReader reader = DirectoryReader.open(w);
+ assertThat(reader.leaves(), hasSize(1));
+ LeafReaderContext singleLeaf = reader.leaves().get(0);
+
+ AtomicInteger visited = new AtomicInteger(0);
+
+ class TrackingScorer extends Scorer {
+ final Scorer scorer;
+
+ TrackingScorer(Scorer scorer) {
+ this.scorer = scorer;
+ }
+
+ @Override
+ public int docID() {
+ return scorer.docID();
+ }
+
+ @Override
+ public DocIdSetIterator iterator() {
+ DocIdSetIterator disi = scorer.iterator();
+ return new DocIdSetIterator() {
+ @Override
+ public int docID() {
+ return disi.docID();
+ }
+
+ @Override
+ public int nextDoc() throws IOException {
+ int docId = disi.nextDoc();
+ if (docId < numDocs) {
+ visited.incrementAndGet();
+ }
+ return docId;
+ }
+
+ @Override
+ public int advance(int target) throws IOException {
+ int docId = disi.advance(target);
+ if (docId < numDocs) {
+ visited.incrementAndGet();
+ }
+ return docId;
+ }
+
+ @Override
+ public long cost() {
+ return disi.cost();
+ }
+ };
+ }
+
+ @Override
+ public float getMaxScore(int upTo) throws IOException {
+ return scorer.getMaxScore(upTo);
+ }
+
+ @Override
+ public float score() throws IOException {
+ return scorer.score();
+ }
+ }
+
+ Weight weight =
+ new ConstantScoreWeight(new MatchAllDocsQuery(), 1.0f) {
+ @Override
+ public ScorerSupplier scorerSupplier(LeafReaderContext context) {
+ return new ScorerSupplier() {
+ @Override
+ public Scorer get(long leadCost) throws IOException {
+ Scorer scorer =
+ ConstantScoreScorerSupplier.matchAll(0f, ScoreMode.COMPLETE_NO_SCORES, numDocs)
+ .get(leadCost);
+ return new TrackingScorer(scorer);
+ }
+
+ @Override
+ public BulkScorer bulkScorer() {
+ return new BulkScorer() {
+ @Override
+ public int score(LeafCollector collector, Bits acceptDocs, int min, int max) {
+ for (int doc = min; doc < max; doc++) {
+ if (doc < numDocs) {
+ visited.incrementAndGet();
+ } else {
+ return DocIdSetIterator.NO_MORE_DOCS;
+ }
+ }
+ return max;
+ }
+
+ @Override
+ public long cost() {
+ return 0;
+ }
+ };
+ }
+
+ @Override
+ public long cost() {
+ return 0;
+ }
+ };
+ }
+
+ @Override
+ public boolean isCacheable(LeafReaderContext ctx) {
+ return true;
+ }
+
+ @Override
+ public int count(LeafReaderContext context) {
+ return context.reader().numDocs();
+ }
+ };
+
+ CountDownLatch latch = new CountDownLatch(numThreads);
+ LRUQueryCache cache =
+ new LRUQueryCache(2, 10000, unused -> true, Float.POSITIVE_INFINITY) {
+ final AtomicBoolean populated = new AtomicBoolean(false);
+
+ @Override
+ protected CacheAndCount tryPopulateCache(
+ IndexReader.CacheHelper cacheKey,
+ Weight weight,
+ ScorerSupplier scorerSupplier,
+ LeafReaderContext context)
+ throws IOException {
+ try {
+ latch.countDown();
+ assertTrue(latch.await(5, TimeUnit.SECONDS));
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ if (populated.compareAndSet(false, true)) {
+ return super.tryPopulateCache(cacheKey, weight, scorerSupplier, context);
+ } else {
+ return null;
+ }
+ }
+ };
+ final Weight cachingWeight = cache.doCache(weight, ALWAYS_CACHE);
+ Thread[] threads = new Thread[numThreads];
+ for (int t = 0; t < numThreads; t++) {
+ int from = t * 100;
+ int to = Math.min(from + 100, numDocs);
+ threads[t] =
+ new Thread(
+ () -> {
+ try {
+ BulkScorer bulkScorer = cachingWeight.scorerSupplier(singleLeaf).bulkScorer();
+ bulkScorer.score(
+ new LeafCollector() {
+ @Override
+ public void setScorer(Scorable scorer) {}
+
+ @Override
+ public void collect(int doc) {}
+ },
+ null,
+ from,
+ to);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ });
+ threads[t].start();
+ }
+ for (Thread thread : threads) {
+ thread.join();
+ }
+ assertThat(visited.get(), lessThanOrEqualTo(numThreads * 100 + numDocs));
+ reader.close();
+ w.close();
+ dir.close();
+ }
+
// A query that returns null from Weight.getCacheHelper
private static class NoCacheQuery extends Query {