LRUQueryCache keeps one cached DocIdSet per (segment, query) pair and, when it is full, evicts the least recently used ones.
- A cached filter is stored per segment: searching
f:xon a 17-segment index creates 17 entries, one DocIdSet each, keyed by (segment, query).maxSizecounts these entries. - #15558 split the cache into 16 partitions to reduce lock contention. Each partition has its own LRU list and evicts on its own once it holds
maxSize / 16entries ormaxRamBytesUsed / 16bytes. - #16657 picks the partition from the query alone, so that a partition can keep one shared copy of each query and count its memory once. Before it, a query's entries were spread over the partitions by a hash of (segment, query).
- Turn the query cache on (it is off by default on main) with the class javadoc's size,
new LRUQueryCache(256, ...), and search the filterf:xtwice on an index with 17 segments. - The filter needs one cache entry per segment: 17 entries, far below the 256 the constructor javadoc allows ("cache at most
maxSizecache entries (each entry being a segment-query pair)"). You expect the second search to hit the cache 17 times, as on 10.5.2. - Instead it gets 0 hits. The cache is 16 partitions of 256 / 16 = 16 entries each, and since #16657 all entries of one query go to the same partition, chosen by
query.hashCode(). - Inserting the 17th entry evicts the oldest one, segment 0's, which the next search needs first; that insert evicts segment 1's, and so on. When each search looks the segments up in the same order, as here, a 17-key cycle through a 16-slot LRU never hits, while the other 15 partitions stay empty.
- Every search rebuilds every DocIdSet. The cliff is at 17 segments for 256 entries and at 63 for the 1,000 that MIGRATE.md recommends.
Applications on main (future 11.0) that turn the query cache on (it is off by default). The 2-arg and 4-arg constructors always use 16 partitions. A filter gets no caching once it spans more than maxSize / 16 segment entries: 17 with 256, 63 with 1,000, counted across all readers sharing the cache. Performance only; results stay correct.
Mechanism, terms and numbers752 words
On Lucene main an LRUQueryCache built with the public 2-arg or 4-arg constructor is split into 16 partitions, and each partition is limited to maxSize / 16 entries and maxRamBytesUsed / 16 bytes (#15558). Since #16657 the partition is chosen from query.hashCode() alone, so every per-segment entry of one query lands in the same partition. A filter cached on more than maxSize / 16 segments therefore evicts its own entries in LRU order and, when every search looks it up on the same segments in the same order (as in the repro: one thread, no executor), never hits: with maxSize 256, the class javadoc's example, a TermQuery over 17 one-document segments gets 0 hits on the second identical search, while the cache holds 16 of its 256 entries and 15 partitions are empty. Results stay correct, but the cache rebuilds every DocIdSet on every search. The verbatim javadoc configuration (2-arg constructor, UsageTrackingQueryCachingPolicy) shows the same over 20 and 34 segments of 10,000 documents. Main only (future 11.0) and opt-in: caching is off by default on main. 10.5.2 and the parent commit of #16657 get all 17 hits. A fix that enforces the budget for the cache as a whole and evicts the oldest entry across partitions passes the test and 2,340 tests of the search package.
- Sixteen hard shares. The 4-arg constructor always passes 16 partitions (
:150); the 2-arg constructor delegates to it (:252). The 6-arg constructor gives each partitionmaxSize / numberOfPartitionsentries andmaxRamBytesUsed / numberOfPartitionsbytes (:191-196). From #15558 (d50fb36d9af). - Routing by query only.
getandputIfAbsentpick the partition withquery.hashCode() & (numberOfPartitions - 1)(:424-426,:435-437), and so doesgetPartitionNumber(:1269-1279, used bycleanUp). The comment at:1273-1276gives the reason: "so that every (segment, query) entry for a given query lands in the same partition. This lets each partition maintain a complete, cache-wide view of the queries it holds (see uniqueQueries), which is what makes per-partition query canonicalization and count-once RAM accounting correct." From #16657 (fadd63e0eb4). - Per-partition LRU eviction. After each insert,
evictIfNecessary(:1223-1248) evicts in access order (LinkedHashMap(16, 0.75f, true),:960) whilerequiresEviction()holds, which compares the partition with its own share:size > maxSize || this.ramBytesUsed > maxRamBytesUsed(:1214-1221). - The cliff. All S per-segment entries of one filter compete for
maxSize / 16slots (16 with 256). A search visits the segments in order; the 17th insert evicts the least recently used entry, the same filter's entry for segment 0. The next search misses there, re-inserts it and evicts segment 1's entry, and so on. Each miss builds the DocIdSet again. The zero holds when every search looks the filter up on all its segments in the same order and nothing else evicts from that partition (the repro: one thread, no executor, an always-cache policy); other visit orders, executor slices or policies that cache only some segments were not run. - Before #16657 the partition came from
QueryCacheKey.hashCode(), then31 * identityHashCode(segment key) + identityHashCode(query), so a query's 17 entries spread over the 16 partitions and all hit. - The memory share works the same way. One query's entries must also fit in
maxRamBytesUsed / 16. One cache shared by several readers, as the class javadoc allows (:80-81), counts all readers' entries of a filter together. The same javadoc also advises "to not share this cache across too many indices" (:70-72).
| setup | second-search hits | entries kept | evictions |
|---|---|---|---|
the repro: 17 one-doc segments, LRUQueryCache(256, 64 MB, ...) | 0 of 17 | 16 (one partition; 15 empty) | 18 |
| 16 segments, same cache | 16 of 16 | 16 | 0 |
17 segments, 6-arg constructor with numberOfPartitions = 1 | 17 of 17 | 17 | 0 |
| 17 segments, maxSize 271 / 272 (16 / 17 per partition) | 0 / 17 | 16 / 17 | 18 / 0 |
| one cache over two 9-segment indices, maxSize 256 | 0 of 18 | 16 | 20 |
the verbatim javadoc config (2-arg constructor, 50 MB, UsageTrackingQueryCachingPolicy), 40 searches, 20 segments of 10,000 docs | 0 of 20 in the last search; 780 DocIdSets built | 16 (22,560 bytes of 50 MB) | 764 |
| the same, 34 segments | 0 of 34; 1,326 built | 16 | 1,310 |
MIGRATE.md's 1,000 entries: 62 / 63 segments (study sweep) | 62 / 0 | ||
| 10.5.2 release jars, the repro and the javadoc config | full hits | 17, 20, 34 | 0 |
Exposure and likelihood203 words
- Who is exposed. Main only (future 11.0), and only applications that configure a query cache: caching is disabled by default on main (
IndexSearcher.java:82-84, CHANGES #14187). Every cache built with the public 2-arg or 4-arg constructor has 16 partitions. - What it takes. A filter cached on more than
floor(maxSize / 16)segments, or on that many (reader, segment) pairs when one cache is shared by several readers: 17 with the class javadoc's 256, 63 withMIGRATE.md's 1,000. With the 2-arg constructor only segments that passMinSegmentSizePredicate(10000)count: at least 10,000 docs and more than half the average docs per segment (LRUQueryCache.java:278-296); the javadoc-config runs use equal 10,000-doc segments, which pass both checks. Ordinary index shapes: #12140 was about a 34-segment index. - Effect. The cache rebuilds every DocIdSet on every search and gives nothing back, while it holds a small fraction of its budget (16 of 256 entries, 22,560 of 52,428,800 bytes in the javadoc config). The study's indicative timing (shared machine, no JMH) put the 16-partition cache at about no-cache cost and a 1-partition cache 3-5x faster for 34 segments.
- Silent. No exception or log; only
getHitCount(),getCacheCount()andgetEvictionCount()show it. - Results stay correct in every run.
The test shows that on main an LRUQueryCache(256, 64 MB, ctx -> true, +inf) never hits for one filter over 17 one-document segments: 16 partitions of 16 entries, and all of one query's entries go to the same partition.
/*
* 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.lucene.search;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.search.DummyTotalHitCountCollector;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestLRUQueryCacheManySegments extends LuceneTestCase {
private static final QueryCachingPolicy ALWAYS_CACHE =
new QueryCachingPolicy() {
@Override
public void onUse(Query query) {}
@Override
public boolean shouldCache(Query query) {
return true;
}
};
public void testFilterOverSeventeenSegmentsIsCached() throws IOException {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig config = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
try (IndexWriter w = new IndexWriter(dir, config)) {
for (int i = 0; i < 17; i++) {
Document doc = new Document();
doc.add(new StringField("f", "x", Field.Store.NO));
w.addDocument(doc);
w.flush();
}
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
LRUQueryCache cache =
new LRUQueryCache(256, 64 << 20, ctx -> true, Float.POSITIVE_INFINITY);
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setQueryCache(cache);
searcher.setQueryCachingPolicy(ALWAYS_CACHE);
Query filter = new TermQuery(new Term("f", "x"));
searcher.search(filter, DummyTotalHitCountCollector.createManager());
searcher.search(filter, DummyTotalHitCountCollector.createManager());
// expected 17 (17 of 256 entries); actual 0: one query's entries share a 16-entry partition
assertEquals("cache hits on the second search", 17, cache.getHitCount());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-054/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.TestLRUQueryCacheManySegments
- Expected vs actual
- Expected 17 cache hits when the same TermQuery is searched a second time (17 entries fit in maxSize 256). Actual: 0 hits; each insert evicts the entry that the next segment's lookup needs.
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: cache hits on the second search expected:<17> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.search.TestLRUQueryCacheManySegments.testFilterOverSeventeenSegmentsIsCached(TestLRUQueryCacheManySegments.java:67)- The javadoc promises "at most
maxSizecache entries (each entry being a segment-query pair)" and LRU eviction to stay under that size; here it evicts at 16 of 256. - The class javadoc says a query's entries "may reside in different partitions"; since #16657 they never do.
- The project's own examples hit it: the javadoc's 256 entries, MIGRATE.md's 1,000.
The full argument417 words
- Contract.
LRUQueryCache.java:61-62("evicts queries using a LRU (least-recently-used) eviction policy in order to remain under a given maximum size and number of bytes used");:138-139and:244-245("cache at mostmaxSizecache entries (each entry being a segment-query pair) with at mostmaxRamBytesUsedbytes of memory"). - Stale placement sentence.
:66-68: "the same query cached against different segments results in separate cache entries that may reside in different partitions" (written by #15558, not updated by #16657). - Example configurations. Class javadoc
:76-86(maxNumberOfCachedQueries = 256, 50 MB, 2-arg constructor, shareable "across several queries and readers");MIGRATE.md:227-236(new LRUQueryCache(1_000, 50 MB)to turn caching back on). - Intent record. In #16657 msfroh suggested moving the canonical
uniqueQueriesmap into the partitions (comment 5625039695). In the reply by the author, sgup432, throughput is the reason given for the earlier (segment, query) routing, and concentration is the trade-off named for routing by query: "the partition number is decided based on the composite key - (segment, query) for better throughput. Until unless we route to partition by queries only ... But the tradeoff being a hot-query segments concentrate in one partition" (comment 5625728241). msfroh noted that "eviction is managed on a per-partition basis" (comment 5626616110); the benchmarks posted on #16657 measure throughput only. In #15558 the author explained that "16 partitions, with each having 1 as maxSize" made a test miss a query's second segment entry, and enlarged the test cache (comment 3752469966). Nobody in #16657 (approved with no review comments), #15558 or #14222 stated what the concentration does to capacity or hit rate: that one query's entries are capped atmaxSize / 16, or that a filter over one more segment gets zero hits. The evidence shows stale documentation and a real cliff, not that the maintainers rejected the cap. - Why tests missed it.
TestLRUQueryCachebuilds caches far larger than its indices, pads sizes by 16 since #15558 (:117-118,:742), or uses one partition (:2236-2238, where the comment "160 so that each partition(total 16 by default) can hold 10 keys at max" sits over a cache built with 1 partition); #16657's three tests use 1- or 2-segment indices (maxSize 1,000,000 at:2859-2901, 100 at:2918-2976). No test asserts hits for one filter over more thanmaxSize / 16segments. - Precedent. #12140 "LRUQueryCache disabled for indices with more than 33 segments" (type:bug, closed as completed in 2023): a cache that silently never helps on a realistic index was treated as a bug.
The devil's-advocate check197 words
Strongest case (the cross-model refute reviewer, rated strong): "at most maxSize" is an upper bound, not a promise that the whole budget is usable under skew; query-only routing is deliberate (:1273-1276); the class is @lucene.experimental (:117); #15558's author knowingly changed what maxSize means, saw 1-entry shares cause a test miss and padded the tests by 16 (#15558 comment 3752469966: maxSize now "denotes the total keys a cache can hold"); #16657's author named the concentration of a hot query's segments in one partition as the trade-off (comment 5625728241); the cache is opt-in and off by default on main; and the repro uses one-document segments and an always-cache policy.
Why we still report it: these facts show that each design choice was deliberate, not that anyone accepted a per-query cap of maxSize / 16 or zero hits beyond it. The cap is undocumented and the class javadoc describes the opposite placement; the documented example sizes hit it, including the verbatim 2-arg configuration on 10,000-doc segments; @lucene.experimental allows API changes, not a silent loss of the cache's benefit. This remains a judgement call: maintainers may choose to document the cap and raise the example sizes.
Recommended Cache-wide budget, evict the oldest entry across partitions (validated by the runs listed, not by a stress run or a benchmark)
Enforce the size and memory budget for the cache as a whole: a partition may grow past its 1/16 share, and when the cache is over budget the oldest entry across all partitions is evicted (LRUQueryCache.java, +96/-25). The test, 2,340 search-package tests and check pass; a full-cache probe behaves like a single-partition cache.
All options and trade-offs4 options · 758 words
Three code options were built and run in a scratch worktree of 645d4fe14a20; a documentation-only answer is listed last. If maintainers see the cap as an accepted trade-off, the cache-wide budget and documenting the cap are the two real choices.
Cache-wide budget, evict the oldest entry across partitions (validated by the runs listed, not by a stress run or a benchmark) (recommended)
Keeps #16657's query-only routing, canonicalization and count-once memory accounting, and #15558's per-partition locks. Each partition may use the whole budget; after each putIfAbsent, evictIfOverBudget() (below) runs without holding any partition lock and, while the cache holds more than maxSize entries or maxRamBytesUsed bytes, evicts the least recently used entry of the partition whose least recently used entry is the oldest. To compare entries of different partitions, QueryMetadata (a record today) becomes a small class with a volatile long lastUsed set to System.nanoTime() on insert and on each lookup; the partition gets eldest() (read lock plus the map's monitor) and evictLeastRecentlyUsed() (the old eviction loop body, same ConcurrentModificationException check). The class javadoc sentence about partitions is corrected.
private void evictIfOverBudget() {
while (getCacheSize() > maxSize || ramBytesUsed() > maxRamBytesUsed) {
LRUQueryCachePartition victim = null;
long oldestLastUsed = 0;
for (LRUQueryCachePartition partition : lruQueryCachePartition) {
final QueryMetadata eldest = partition.eldest();
if (eldest != null && (victim == null || eldest.lastUsed - oldestLastUsed < 0)) {
victim = partition;
oldestLastUsed = eldest.lastUsed;
}
}
if (victim == null || victim.evictLeastRecentlyUsed() == false) {
return;
}
}
}Trade-offs
- One
System.nanoTime()and a volatile write per lookup; when the cache is over budget, each eviction round peeks 16 partitions (16 read-lock acquisitions). No benchmark was run (LRUQueryCacheBenchmarkexists inlucene/benchmark-jmh). - The totals are read without a global lock, so under concurrent inserts the cache can briefly exceed the budget or evict one entry too many: close to, not exactly, a single cache-wide LRU.
- Side effect: a cache with
maxSize < 16now caches, which changes small-cache behaviour the #15558 author knew of and accepted. Not changed: the memory eligibility guardmaxRamBytesUsed / numberOfPartitions(:800), a separate question.
What the checks validated
- The test: stock fails, + fix passes, with assertions on and off, through gradle and the standalone runner.
- + fix, gradle, seed DEADBEEF:
TestLRUQueryCache, the new test,TestUsageTrackingFilterCachingPolicy,TestBooleanScorer,TestDocValuesOrdinalSetIterator: 89 tests, 3 skipped (@AwaitsFix, skipped on stock too), 0 failures, assertions on and off;TestLRUQueryCachealso with seeds 5EED1234 and C0FFEE; the wholeorg.apache.lucene.search.*package: 2,340 tests, 49 skipped, 0 failures (the test framework installs anLRUQueryCacheas the default cache);:lucene:core:check -x testpasses. - Shadowed into the stock jars: the verbatim javadoc config gets full hits on 12, 20 and 34 segments with 0 evictions; the tie-break probe's 5 tests pass (shared cache 18 of 18, scored MUST + FILTER 17 of 17).
- Full-cache probe (8 segments, maxSize 64, each round one hot filter then a new one-off filter, 40 rounds): hot-filter hits in rounds 20-39 are 0 of 160 on stock and 160 of 160 with the fix, the same as a 1-partition cache (also 264 evictions). Not run: the full test suite, nightly tests, a concurrency stress run, a benchmark.
The same budget change, but the victim is the partition that holds the most entries (or bytes). The repro and the same suites pass.
Trade-offs
In the full-cache probe the hot filter gets 0 of 160 hits in three runs: its partition is the largest, and its least recently used entry is the next one it needs, so the cyclic scan comes back once the cache is full.
Use QueryCacheKey.hashCode() for the partition, as before #16657 (+6/-11). The repro passes.
Trade-offs
#16657's own tests testQueryRamAccountedOncePerDistinctQuery ("8656 should be < 2x 4168") and testCanonicalQueryInstanceSharedAcrossSegments fail: a query is canonicalized and its memory counted once per partition, which #16657 fixed. The hard 1/16 shares stay, so an uneven spread can still evict early. It could be combined with a cache-wide canonical-query map, which the #16657 author discussed (reference counts or weak references).
State that one query's entries are limited to maxSize / numberOfPartitions entries and bytes, correct the class javadoc's partition sentence, and raise the example sizes in the javadoc and MIGRATE.md; or use fewer partitions for small caches.
Trade-offs
Leaves the cliff for anyone who sizes the cache by the entries a workload actually needs.
Servers that share one cache across all shards of a node, as the class javadoc allows, would cap each filter at 1/16 of that cache across all shards together (inferred from the shared-cache probe; not run downstream).
Code reading while building a TLA+ model of LRUQueryCache in the 2026-10-22 study flagged the per-partition split. The study's verifier then found, with its own tests, that since #16657 one filter over more than maxSize / 16 segments never hits; the hunt reduced that to one JUnit test.
The method in full113 words
Method: code reading (non-formal tier). The study's "search-concurrency-model" unit was building a TLA+ model of the cache's concurrency; while reading the code it noted that maxSize < 16 caches nothing and that each partition gets 1/16 of the memory. A verifier (search concurrency model) reproduced those claims and found the larger one: since #16657 one filter over more than maxSize / 16 segments never hits, using the verbatim class-javadoc configuration, a shared cache, a sweep of the cliff, and older LRUQueryCache sources on the main classpath to attribute it to #16657. The hunt then kept only that claim, wrote the one-test repro (repro-minimal/), bisected it with single-commit builds and re-verified it.
No prior report: 99 queries on 2026-10-29 over GitHub issues, PRs and commits, four mailing lists, the JIRA archive, downstream repositories and the web, plus every open PR's file list. The #15558 author noted in public that 1-entry partition shares made a small test cache miss, a smaller case of the same mechanism before query-only routing.
Searches6 searches
Closest items: #15558 (partial: its author explained that a test missed a query's second segment entry because "16 partitions, with each having 1 as maxSize" meant "a specific partition can hold 1 key at max", and enlarged the test cache; a small test cache, before query-only routing, with no many-segment filter); #16657 (introduced the routing; its author named the concentration of a hot query's segments in one partition as the trade-off, its benchmarks measure throughput only, and nobody discussed capacity or hit rate); Elasticsearch #163523 (open, 2026-10-27; its body says the new cache "is partitioned (separate locks per partition) and keyed by (query, segment) so a lookup hashes to one partition", that "One query on N segments is N entries, not one", and calls the new maxSize "a tighter, different limit"; its muted tests, where getHitCount stays 0 with new LRUQueryCache(10, ...), are by our reading the maxSize < 16 case; it does not mention the per-partition size share, routing by query alone or the many-segment cliff, and attributes the 0-hit tests to the old hooks); #12140 (precedent, different cause); #14222 and #10081 (concurrency); open PRs #16306, #16757 and #16847 change other parts.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-29 | scripted prior-report search: 45 issue/PR queries (LRUQueryCache, LRUQueryCachePartition, numberOfPartitions, getPartitionNumber, query cache partition, eviction, hit rate) plus manual searches; every issue and PR updated since 2026-10-15 with their comments | no report of this bug |
| Open PRs | 2026-10-29 | file lists of all 415 open PRs | #16757 and #16847 touch LRUQueryCache.java, neither changes sizing or routing |
| Commits | 2026-10-29 | scripted prior-report search: 6 commit queries; git log -S numberOfPartitions | only #15558 and #16657 |
| Mailing lists | 2026-10-29 | scripted prior-report search: 32 queries, 8 each over dev@, java-user@, issues@, commits@ | no discussion of partition capacity |
| JIRA archive | 2026-10-29 | scripted prior-report search: 6 queries | nothing on partitions (the code is from 2026) |
| Downstream and web | 2026-10-29 | scripted prior-report search: 4 downstream-repository queries and 6 web queries | Elasticsearch #163523 (a different sub-case, by our reading) |
Introduced on main by #16657 (fadd63e0eb4, 2026-09-29), which routes by query, on top of #15558's 16 hard shares (d50fb36d9af, 2026-04-14). Bisected by execution; no release or release branch has either change.
Versions tested5 rows
#15558 (sgup432, merged 2026-04-14) replaced the single cache-wide LRU with 16 partitions, each limited to 1/16 of the budget, and routed entries by a hash of (segment, query); a query's entries spread over the partitions, so the repro scenario still gets 17 hits there. #16657 (sgup432, approved by msfroh, merged 2026-09-29; commit body "Also route to partition by query only") fixed the cache key's hashCode/equals and counted a query's memory once per distinct query, and for that routed by query.hashCode() alone. Both are listed under 11.0.0 in CHANGES; #15558 was deliberately not backported to 10.x.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | 0 of 17 hits; the test fails with assertions on and off |
main fadd63e0eb4 (#16657) vs its parent 1adb280101d | bisect | #16657: 0 of 17; its parent: 17 of 17 (-ea and -da) |
main d50fb36d9af (#15558) and its parent 55b4574a20d | not affected | 17 of 17 (entries spread by (segment, query) hash) |
10.6.0 RC1 revision 4ae946e5a9b | not affected | 17 of 17; no partitions on branch_10_6 |
| 9.12.3, 10.0.0 to 10.5.2 (13 releases) | not affected | 17 of 17 (one cache-wide LRU) |
The study's verifier, the verify step and a tie-break re-ran it on main and 10.5.2; a cross-model refute reviewer could not run Java itself (a separate run ran the repro: 1 failure), agreed on the root cause and argued "intended", which the second adjudication weighed and rejected.
Checks and verdicts260 words
Each check re-ran the repro or its own tests; every number matched.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | new bug (performance) | Own tests on main and 10.5.2, including the verbatim javadoc config, a shared cache, a cliff sweep and older sources on the main classpath; attributed the thrash to #16657. |
| verify | real | Fails on main with -ea and -da (one run each), plus 10 of 10 derived seeds with -da; passes on 10.5.2. The tie-break added 5 of 5 derived seeds with -ea. |
| cross-model refute review | not real, low confidence (procedural) | An independent reviewer from another model family (OpenAI GPT-5.6). It could not run Java itself in either attempt; a separate run ran the candidate repro on main (1 test, 1 failure, the expected assertion), and a re-run gave the same with -ea and -da and a pass on 10.5.2. On substance it agreed with the root cause and that the inputs are in contract, and rated the intended-behaviour case strong. |
| Second adjudication | real | High confidence on mechanism and reproduction, medium-high on bug versus accepted trade-off; its own probe shows that one query keeps at most floor(maxSize / 16) entries, that zero hits start at floor(maxSize / 16) + 1 cacheable entries (maxSize 271: 0 of 17; 272: 17 of 17), and the shared-cache and scored-query forms. |
| history | introduced by #16657 | Single-commit builds bisect the cliff to fadd63e0eb4; every release passes. |
| brief | reproduced; fix validated | Re-ran the test on main and 10.5.2 and built three fix options. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 611 KB
Finding record, Lucene and upstream sources, repro files and diagram notes557 words
- Registry title
- LRUQueryCache's 16 hard partitions give a 0% hit rate with the class-javadoc configuration (main only)
- Area
- search / query cache
- Priority
- Low, rank 715: On main only, the opt-in query cache's 16 hard partitions give a 0% hit rate with the documented 256- or 1000-entry configs once a filter spans more than 16 (or 63) segment entries, performance only and fixable before release.
- Impact class
- minor
- Impact note
- Main only (unreleased); the query cache is opt-in on main (#14187) and every cache built with the public constructors has 16 partitions; performance only, results stay correct.
- Summary
- On main, you build an LRUQueryCache with the configuration from its class javadoc (maxSize 256, 50 MB) and run one filter over more than 16 segments. You expect the filter to be cached. Instead maxSize and RAM are hard-split across 16 partitions routed by query, so it gets a 0% hit rate forever; maxSize < 16 caches nothing, and partition counts that are 0, negative or not a power of two crash or waste capacity.
- Introduced
- #15558 (d50fb36d9af) and #16657 (fadd63e0eb4) (2026-04-14 (#15558 merged); 2026-09-29 (#16657))
- First affected version
- unreleased (main only; #15558 not backported to 10.x)
- Affected versions
- main [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28, upstream main on 2026-10-29): the repro test through gradle in a scratch worktree and through the standalone runner, assertions on and off, stock and with three fix variants; the 10.5.2 release jars (passes). The hunt's history runs (follow-up) also ran a probe of the same scenario on 9.12.3, every release from 10.0.0 to 10.5.2, the 10.6.0 RC1 revision4ae946e5a9b,55b4574a20d(parent of #15558),d50fb36d9af(#15558),1adb280101d(parent of #16657) andfadd63e0eb4(#16657); the study ran its tests on main7cecf79523, whereLRUQueryCache.javais byte-identical.
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:61-86, :145-151, :169-196, :243-253, :420-440, :960, :1214-1248, :1269-1279, :1327, :1392-1413
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84; lucene/CHANGES.txt:343 (#14187)a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:227-236a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:243-253, :278-296 (2-arg constructor, MinSegmentSizePredicate)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:61-62, :66-68, :76-86, :117, :137-139, :243-245, :278-296, :1273-1276a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:227-236a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:117-118, :742, :2236-2238, :2859-2901, :2918-2976
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:792-801 (memory eligibility guard)
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:212, :281-282
- TestLRUQueryCacheManySegments.java (JUnit test):
repro/LFV-054/src/org/apache/lucene/search/TestLRUQueryCacheManySegments.java(71 lines)
It is made in parallel with this doc; its values come from the code at 645d4fe14a20, the diagram's executed trace probe and the follow-up.
assets/LFV-054/bug.png(611 KB)
