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 {