A range filter or drill-down built with getQuery(fastMatchQuery, valueSource) checks only the documents its fast-match query matches. Before reusing a cached result on a segment, the query cache asks the query whether that is safe after doc-values updates; this query answers for its value source only.
- Index 20 documents in one segment, price 10 each; docs 0-9 are shoes, and docs 0-4 are in stock (
inStock= 1, a doc-values field). Drill down on price 0-100 withLongRange.getQuery(fastMatch, price), the fast match being "shoes AND inStock == 1". Through a query cache: 5 hits. - Doc 0 sells out:
updateNumericDocValue(id:0, inStock, 0), then reopen the reader. You expect 4 hits, and an uncached searcher on the new reader returns 4. - Instead the same search through the cache returns 5, doc 0 included. Asked "is your cached result still valid?", the range query checks only its price field, which did not change, and never asks the fast match, which would say no. An in-place update keeps the segment's cache key, so the old entry is served.
- It lasts until the segment is merged, the entry is evicted, or the price field itself is updated in that segment. On 7.2.0-10.5.2 the default
IndexSearchercache is enough; main has no default cache, so the application must install one. A points or term fast match, the documented use, is not affected.
Applications that put a doc-values condition (an in-stock or visibility flag) into the fast match of a LongRange/DoubleRange range filter or drill-down, update that field in place, and keep a query cache across reopens: the default IndexSearcher cache on 7.2.0-10.5.2, an installed one on main. Rare: every fast match Lucene ships or tests is a points, postings or match-all query.
Mechanism, terms and numbers827 words
LongRange#getQuery(Query fastMatchQuery, LongValuesSource) and its three siblings (the MultiLongValuesSource overload and the two DoubleRange ones) return a query that only checks the documents its fast-match query matches. Its Weight iterates the fast-match Weight's scorer, but its isCacheable asks only the value source (return valueSource.isCacheable(ctx);, LongRange.java:195-198 and :302-305, DoubleRange.java:208-211 and :315-318 on main a9779ef3c193). LRUQueryCache trusts Weight#isCacheable and keys entries on the segment's core cache key, which does not change on a doc-values update. So when the fast-match query reads a doc-values field that is updated in place (IndexWriter#updateNumericDocValue, updateBinaryDocValue or updateDocValues), the entry cached before the update keeps being served after the reopen: 5 hits instead of 4 in the repro, with and without assertions, on main and on every executed release from 7.2.0 to 10.5.2. The fast-match query on its own is correctly uncacheable on that segment. On 7.2.0-10.6.0 (10.6.0 by reading) the default IndexSearcher cache reproduces it with zero configuration; main has had no default cache since #14187 (unreleased), so there it needs an installed cache, for example the MIGRATE.md snippet. Introduced by LUCENE-8042 (276e317e9424), which replaced a never-cacheable "TODO delegate to LongValuesSource?" with the value-source check and left out the fast-match Weight built in the same method. The fix ANDs fastMatchWeight.isCacheable(ctx) into the four overrides.
- Fast-match query: the first argument of
LongRange/DoubleRange#getQuery(Query fastMatchQuery, valueSource). "The query will check all documents that match the provided match query" and "@param fastMatchQuery a query to use as a filter" (LongRange.java:313-314, 319, 335;DoubleRange.java:326-327, 332, 348). The range check itself reads the value source. The query is the usual way to drill down on a range facet (DrillDownQuery#add, asTestRangeFacetCountsdoes). - Query cache:
LRUQueryCachestores the matching documents of a query per segment, keyed on the query and the segment's core cache key. It asksWeight#isCacheable(leaf)before it reads or writes an entry (LRUQueryCache.java:816-834and:903-921). - In-place doc-values update:
IndexWriter#updateNumericDocValueand friends change a doc-values field without rewriting the segment. The reopened segment may keep its core key: "Two readers that have the same data but different sets of deleted documents or doc values updates may be considered equal" (LeafReader.java:66-70). In every run the reopened segment kept its key (sameCore=true). That is why a query that reads an updated doc-values field must say it is not cacheable: "Updated doc values fields are not suitable for caching" (SegmentCacheable.java:30-32).
createWeightbuilds the fast-match Weight once and iterates its scorer; the value source is only the second-phase check (LongRange.java:156-190).isCacheablereturnsvalueSource.isCacheable(ctx)and nothing else (LongRange.java:195-198; the same at:302-305,DoubleRange.java:208-211and:315-318).- A doc-values fast match such as
NumericDocValuesField.newSlowExactQuery("inStock", 1)reportsDocValues.isCacheable(ctx, "inStock"), false once the field was updated in that segment (DocValues.java:340-346); a smallBooleanQueryfast match like this two-clause one ANDs its clauses' answers (one with more clauses thanBOOLEAN_REWRITE_TERM_COUNT_THRESHOLDis never cacheable;BooleanWeight.java:273-285). The range Weight never asks it. - After the update and reopen, the cache asks the range Weight, gets true, finds the old entry under the unchanged core key and returns it. The fast-match Weight is not consulted, because the outer entry is served before the range Weight's scorer is built.
20 documents, one segment, price 10 each; docs 0-9 shoes, docs 0-4 in stock. DrillDownQuery(MatchAllDocsQuery) plus add("price", LongRange[0, 100].getQuery(shoes AND inStock == 1, price)) counts 5. After updateNumericDocValue(id:0, inStock, 0) and openIfChanged: uncached 4, cached 5. The same for all four getQuery overloads used as plain filters. The range Weight's isCacheable is true on the updated segment while the fast-match Weight's is false. With an allow flag in the fast match, revoking doc 0 and granting doc 15 returns docs [0..9] where the uncached answer is [1..9, 15]: the wrong answer goes both ways for the changed documents.
- Needs: a fast match that reads a doc-values field, an in-place update of that field, a range query cached on that segment before the update, and a cache that survives the reopen. The range query must run without scores, as a count, a filter or a drill-down dimension does: only then does
IndexSearcher#createWeightwrap it in the cache (IndexSearcher.java:976-982). - Not affected: a points or postings fast match (the distance demo's bounding box, the
LongPoint/DoublePointranges inTestRangeFacetCounts), anullfast match, the same restriction in the base query or as its own clause, a range query first cached after the update. Deleted documents stay hidden. - The stale entry survives later unrelated updates and reopens; it ends when the segment is merged into a new one, the entry is evicted, or the range's own value field is updated in place in that segment, or the application clears, closes or replaces the cache. The duration in a running application was not measured.
Exposure and likelihood283 words
- 7.2.0 to 10.5.2: the default configuration.
IndexSearcherhas a JVM-wide defaultLRUQueryCache(1000, min(32 MB, heap/20))withUsageTrackingQueryCachingPolicy(10.5.2IndexSearcher.java:83-99). It caches only segments of at least 10,000 documents that are large relative to the index, and a query only after repeated use. One 12,000-document segment and the query run 6 times reproduce it with no cache code at all on 9.12.3 and 10.5.2 (5 instead of 4). - main: opt-in. There is no default cache since #14187 (
IndexSearcher.java:84, not yet released).MIGRATE.md:227-236shows how to turn it back on; with exactly that snippet and otherwise default settings the bug is back on main (5 instead of 4). - Effect. Silent wrong hit counts, hit lists and drill-downs for the documents whose flag changed; no exception. With the shared default cache, any searcher in the JVM that runs the same query without scores on the same segment core can get the stale entry.
- Scale. Ordinary; nothing large is needed.
- How likely: rare. Every fast match that Lucene ships or tests is a points, postings or match-all query (
TestRangeFacetCounts.java:1673-1676passesMatchAllDocsQuery), none of which can be updated in place; no facet test updates doc values; code search found no Elasticsearch, OpenSearch or Solr code that runs these queries with a Lucene query cache. The realistic trigger is a selective fast match that ANDs a postings clause with a doc-values flag, the use the doc-values query javadoc implies (such queries are "slow if they are not ANDed with a selective query",NumericDocValuesField.java:134-135; its explicit advice is to wrap them in anIndexOrDocValuesQuerywith a points query,:135-137, and a field updated in place is doc-values only, so it has no points).
The test shows that a LongRange#getQuery(fastMatch, valueSource) query whose fast match reads doc values (inStock) keeps its query-cache entry after updateNumericDocValue changes inStock and the reader is reopened.
/*
* 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.facet.range;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
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.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.LRUQueryCache;
import org.apache.lucene.search.LongValuesSource;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.QueryCachingPolicy;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestRangeFastMatchQueryCache extends LuceneTestCase {
public void testCachedRangeQuerySeesFastMatchDocValuesUpdate() throws Exception {
Query query =
new LongRange("r", 0, true, 100, true)
.getQuery(
NumericDocValuesField.newSlowExactQuery("inStock", 1),
LongValuesSource.fromLongField("price"));
LRUQueryCache cache = new LRUQueryCache(1000, 1 << 20, ctx -> true, Float.MAX_VALUE);
QueryCachingPolicy alwaysCache =
new QueryCachingPolicy() {
@Override
public void onUse(Query q) {}
@Override
public boolean shouldCache(Query q) {
return true;
}
};
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new StringField("id", "0", Field.Store.NO));
doc.add(new NumericDocValuesField("inStock", 1));
doc.add(new NumericDocValuesField("price", 10));
w.addDocument(doc);
try (DirectoryReader r1 = DirectoryReader.open(w)) {
IndexSearcher s1 = new IndexSearcher(r1);
s1.setQueryCache(cache);
s1.setQueryCachingPolicy(alwaysCache);
s1.count(query);
w.updateNumericDocValue(new Term("id", "0"), "inStock", 0);
try (DirectoryReader r2 = DirectoryReader.openIfChanged(r1, w)) {
IndexSearcher s2 = new IndexSearcher(r2);
s2.setQueryCache(cache);
s2.setQueryCachingPolicy(alwaysCache);
// expected 0 hits (inStock is now 0); actual 1, the cached pre-update result
assertEquals("cached count after the in-place inStock update", 0, s2.count(query));
}
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-320/, 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:$LUCENE/lucene/facet/build/libs/lucene-facet-$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.facet.range.TestRangeFastMatchQueryCache
# the module(s) facet also need their third-party runtime jars
- Expected vs actual
- Expected: 0 hits (the only document is no longer in stock). Actual (-ea and -da): 1 hit, the cached pre-update result, because the range Weight's
isCacheableignores the fast-match Weight.
More files, environment, full output40 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: cached count after the in-place inStock update expected:<0> but was:<1>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.facet.range.TestRangeFastMatchQueryCache.testCachedRangeQuerySeesFastMatchDocValuesUpdate(TestRangeFastMatchQueryCache.java:72)- The cache contract: objects that depend on doc values must report updated fields as not cacheable (
SegmentCacheable.java:30-32); a core test: "all composites referring to it should be uncacheable". - Composites (
BooleanWeight,ConstantScoreQuery,FunctionScoreQuery) AND their sub-weights' answers; LUCENE-8042 left out the fast-match Weight. - Nothing restricts the fast match: it is documented as "a query to use as a filter".
The full argument288 words
- Contract.
SegmentCacheable.java:30-32: "Objects that depend on doc values should return DocValues#isCacheable(LeafReaderContext, String...), which will check to see if the doc values fields have been updated. Updated doc values fields are not suitable for caching."QueryCache.java:28-30: the caching wrapper "will only be equivalent if scores are not needed"; the repro counts without scores and gets 5 instead of 4. - Upstream test.
TestSegmentCacheables.java:43, 84: "after field3 is updated, all composites referring to it should be uncacheable". - Precedent in core and queries.
BooleanWeight.java:273-285,ConstantScoreQuery.java:193-195andFunctionScoreQuery.java:280-282(inner.isCacheable(ctx) && valueSource.isCacheable(ctx)) combine their sub-weights. LUCENE-8042 ANDed the sub-weights in the Weights that already combined them throughgetCacheHelper(context, weights)(BooleanWeight,DisjunctionMaxQuery,DrillSidewaysQueryand others). Where it replaced areturn null; // TODO delegate to ...it asked the value source only: in these range Weights, inFunctionScoreQueryand in spatial-extrasCompositeVerifyQuery.FunctionScoreQuery's inner Weight was ANDed in five days later by LUCENE-7736 (2a4dd499bbdon master,03afeb7766aon branch_7x, committed 2017-11-15), before 7.2.0: a precedent for the fix. - History. LUCENE-8017 (
a886a001a4c) made these Weights never cacheable:return null; // TODO delegate to LongValuesSource?. LUCENE-8042 (276e317e9424, by romseygeek, +1 by rmuir, 7.2.0) resolved the TODO by delegating to the value source only; thefastMatchWeightwas already in the same method. No comment on #9090, #9065 or #543 mentions the fast match; in #543 gsmiller wrote "I think we still needisCacheablethough for the query implementations inLongRangeandDoubleRange". - Docs. No javadoc, CHANGES or MIGRATE entry says the fast match must be segment-immutable;
getQuerycalls it "a query to use as a filter" andLongRangeFacetCountssays "only documents passing the filter are checked" (LongRangeFacetCounts.java:77, 101).
The devil's-advocate check246 words
The intended-behaviour checker and a reviewer from another model family (OpenAI GPT-5.6) built the strongest case: (a) every fast match Lucene ships or tests is a points, postings or match-all query, and doc-values queries are documented as slow; (b) IndexOrDocValuesQuery#isCacheable also asks only one sub-query (IndexOrDocValuesQuery.java:211-214), and spatial-extras CompositeVerifyQuery ignores its indexQuery the same way (:114-115); (c) the classes are @lucene.experimental; (d) main has no default cache; (e) in #543 romseygeek asked "possibly we don't need to worry about caching either given how these are used?".
Why it fails: (a) convention is not a contract; a selective postings-plus-doc-values fast match is the use the doc-values javadoc implies, and it triggers the bug. (b) IndexOrDocValuesQuery relies on its documented precondition "Both provided queries must match the same documents" (:61); the range query ANDs two different predicates on different fields. CompositeVerifyQuery got its value-source-only answer in the same LUCENE-8042 change: a second instance of the pattern, not a documented decision. (c) Experimental allows API changes, not wrong results. (d) The cache is public, MIGRATE.md shows how to re-enable it, and it is the default in every release since 7.2.0. (e) The remark asked whether the new facet values-source abstractions needed caching support at all (next to dropping needsScores() and rewrite()); gsmiller replied that isCacheable is still needed for the LongRange and DoubleRange queries, and it was kept. Verdict: not intended; no checker rated the intended case better than weak.
Recommended Also ask the fast-match Weight (validated)
In the four isCacheable overrides, also ask the fast-match Weight: return (fastMatchWeight == null || fastMatchWeight.isCacheable(ctx)) && valueSource.isCacheable(ctx); (valuesSource in LongRange.MultiValueSourceQuery; +8/-4 in LongRange and DoubleRange). The new test passes 8 of 8 and the whole facet module (314 tests) passes, with assertions on and off; without updates a cacheable fast match keeps the range query cached.
@Override
public boolean isCacheable(LeafReaderContext ctx) {
return (fastMatchWeight == null || fastMatchWeight.isCacheable(ctx))
&& valueSource.isCacheable(ctx);
}All options and trade-offs3 options · 431 words
One fix was built and validated; two alternatives were not built.
The same two-line change in LongRange.ValueSourceQuery, LongRange.MultiValueSourceQuery, DoubleRange.ValueSourceQuery and DoubleRange.MultiValueSourceQuery (2 files, 4 hunks, +8/-4; in LongRange.MultiValueSourceQuery the field is named valuesSource). It is what FunctionScoreQuery does for its inner query.
@Override
public boolean isCacheable(LeafReaderContext ctx) {
return (fastMatchWeight == null || fastMatchWeight.isCacheable(ctx))
&& valueSource.isCacheable(ctx);
}Trade-offs
- On a segment where a field the fast match reads was updated in place, the range query is no longer cached until that segment is merged away. The fast-match query on its own is already treated that way, so nothing new is lost.
- Without updates, a cacheable fast match changes nothing: the range query is still served from the cache (the brief's control test, one fast-match shape; on the introducing commit the fix keeps both cache entries).
- A fast match that is not cacheable for another reason ("global statistics or scores",
SegmentCacheable.java:34-35) now also keeps the range query out of the cache, with or without updates: correct, but a change in what gets cached. - At most one extra
isCacheablecall, on the fast-match Weight, per leaf cacheability check; none for anullfast match. A "Bug Fixes" CHANGES line would be usual; none is drafted.
What the checks validated
- Scratch worktree of
a9779ef3c193, gradle: the new test fails 6 of 8 on the stock tree and passes 8 of 8 with the fix, with assertions on and off; the wholelucene/facetmodule (40 suites, 314 tests, among themTestRangeFacetCountsandTestDrillDownQuery) passes with assertions on and off;:lucene:facet:check -x test(format, ecjLint, forbiddenApis) passes. - Standalone runner with the fix shadowed in: the original test 5 of 5, the independent test 7 of 7, the drill-down probe 3 of 3, the allow/deny-flag probe 3 of 3; the
MIGRATE.mdprogram printsdefault searcher 4, uncached 4. - On the introducing commit
6e4f9a62e7ccthe same change is correct and keeps both cache entries. - Not run: nightly tests, a benchmark.
Return (or rewrite to) a BooleanQuery with the fast match and a range query without fast match as two FILTER clauses; BooleanWeight then combines cacheability.
Trade-offs
More invasive for the same result: it changes how iteration and costs are planned and how toString and equals look. Not built.
A javadoc note on the four getQuery methods.
Trade-offs
Leaves the trap for anyone who misses the note, and documents an exception to the SegmentCacheable contract instead of following it. Not built.
An application that enforces visibility through a doc-values flag in this fast match keeps returning documents whose flag was revoked, and misses newly allowed ones, until the stale entry goes; deleted documents stay hidden.
A hunt for query-cache correctness bugs: a bytecode scan of all 123 classes that declare isCacheable flagged these four Weights as asking their value source only. A cached-versus-uncached sweep of 35 doc-values queries after an in-place update showed them stale (5 versus 4), and a public-API JUnit confirmed it on the stock jars.
The method in full197 words
Method: semi-formal (the hunt's query-cache hunt, 2026-10-26, main ea57360912ed).
- Scan. A bytecode scan with the JDK class-file API over every module jar resolved the
isCacheablebody of 123 classes and classified them (constant, doc-values check, delegation). The four range Weights came out as "delegate: value source only"; reading the code showed that the fast-match Weight they iterate is not consulted. - Sweep. A differential harness on the real jars ran 35 doc-values query cases (27 ok, 8 STALE): fill an always-cache
LRUQueryCache, update a field in place, reopen with the same segment core, compare cached and uncached results. The fourgetQuery(fastMatch on a doc-values field, ...)cases were STALE (cached 5, uncached 4); every other STALE case belongs to two sibling bugs with separate fixes. - Model. A TLA+ model of the cache path (isCacheable guard, core-keyed entries, NRT reopen, in-place update, merge, eviction), written afterwards, holds for correct cacheability shapes and violates its SearchCorrect invariant with a short counterexample for a query that checks a strict subset of the fields it reads.
- JUnit. The finder's public-API test failed 5 of 5 on the stock jars with assertions on and off, 100 of 100 over 20 iterations.
No prior report found (searched 2026-10-26, re-checked 2026-10-27): 354 logged queries over GitHub issues, PRs and commits of Lucene, Solr, Elasticsearch, OpenSearch and lucenenet, the JIRA archive, the mailing lists and the web, plus the changed files of all 411 open Lucene PRs. The nearest items are LUCENE-8042 and #543, which introduced and copied the code.
Searches6 searches
Related, not reports of this bug: #9090 (LUCENE-8042, introduced it) and #543 (LUCENE-10245, copied it), with no fast-match discussion; #9065 (LUCENE-8017), the same bug class in FunctionRangeQuery/FunctionMatchQuery, fixed; #6481 (LUCENE-5418), which introduced the fast match; #13375 and #13383, a speed rewrite of the same classes; #11098 (LUCENE-10060), another facet caching bug; #8398 (LUCENE-7344), where a 2020 comment by dsmiley states the general rule that isCacheable must be false with doc-values-dependent clauses. The only open PR touching the files, #16847, does not change isCacheable. Gaps: keyword search only; GitHub Discussions and chat channels were not searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, commits (apache/lucene) and downstream repositories | 2026-10-26 | a scripted prior-report search in 3 runs (32 issue/PR, 6 commit, 5 downstream queries) and 134 read-only GraphQL searches over apache/lucene, Elasticsearch, OpenSearch, Solr, lucene-solr and lucenenet | no report; only the related items below |
| Open PRs | 2026-10-26 | the changed files of all 411 open apache/lucene PRs | only #16847 touches the files (maxDoc to maxLeafDoc), not isCacheable |
| Mailing lists | 2026-10-26 | 56 tool queries and 56 manual list-archive queries over dev@, java-user@, issues@ and commits@ | no report; review mails of #543 matched only through diff context |
| JIRA archive | 2026-10-26 | 12 tool queries and 24 JQL queries over LUCENE and SOLR; LUCENE-8017, 8042 and 10245 read | no report |
| Web | 2026-10-26 | 16 queries by a scripted prior-report search (2 of its 3 runs) and 10 manual queries | no report |
| GitHub (re-check) | 2026-10-27 | 5 searches (fastMatchQuery isCacheable, LongRange getQuery cache, isCacheable fastMatch, range drill down stale cache doc values, open PRs LongRange); upstream main 20ced71c1e6e read | nothing new; the four isCacheable bodies unchanged |
Introduced in 7.2.0 by LUCENE-8042 (276e317e9424, 2017), which made these queries cacheable but asked only the value source; #543 copied it into the multi-valued overloads (released in 9.1.0 through its branch_9x backport). Executed: the introducing commits are stale and their parents correct; every executed release from 7.2.0 to 10.5.2 and main is stale.
Versions tested9 rows
Introduced by 276e317e9424 on master, "LUCENE-8042: Add SegmentCachable interface" (romseygeek, authored 2017-11-07, committed 2017-11-10; JIRA patch workflow, migrated as #9090), in 7.2.0 through the branch_7x backport 6e4f9a62e7cc. It replaced LUCENE-8017's never-cacheable return null; // TODO delegate to LongValuesSource? with valueSource.isCacheable(ctx). 82703757fe05 (main; #543, LUCENE-10245, gsmiller, merged 2022-01-10; first released in 10.0.0) and its branch_9x backport 60b499b907d (first released in 9.1.0) copied the body into the multi-valued overloads.
7.1.0 also returns the stale count, but for an older, broader reason: the cache then ignored doc-values updates for every query, so the fast-match query alone is stale too; LUCENE-8017 fixed that in 7.2.0. Every isCacheable body that exists is unchanged at all 62 release tags from 7.2.0 to 10.5.2 (the two single-valued bodies at the 30 tags 7.2.0-9.0.0, all four from 9.1.0), and the four are unchanged on upstream main 20ced71c1e6e (2026-10-27). 10.6.0 (released 2026-10-29) and releases before 7.0 were not executed.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the brief's test 6 of 8 fail (gradle and the standalone runner, assertions on and off; 10 iterations); the MIGRATE.md program 5 instead of 4 |
main ea57360912ed | affected | the original test 5 of 5, the independent test 5 of 7, the drill-down probe 2 of 3 (assertions on and off); no default cache, so the zero-configuration program is correct |
| 10.5.2 | affected | the brief's test 6 of 8 (assertions on and off); zero configuration 5 instead of 4 |
| 9.1.0, 9.12.3, 10.0.0 | affected | the original test 5 of 5 (all four overloads); zero configuration on 9.12.3 5 instead of 4 |
| 7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0 | affected | single-valued test 2 of 3 fail, the fast-match control passes (the multi-valued API arrived in 9.1.0) |
| 7.1.0 | older root cause | 3 of 3 fail, the fast-match query alone too: the pre-7.2.0 cache ignored doc-values updates for every query |
faa4293090ae, 281e84b988a5 (parents) | commit isolation | correct: the range query was never cached |
6e4f9a62e7cc, 276e317e9424 (LUCENE-8042) | commit isolation | stale (5 instead of 4); the fast-match query alone correct |
| main + fix | not affected | the brief's test 8 of 8; the whole lucene/facet module, 314 tests, 0 failed (assertions on and off) |
An independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6, reading-only) agree on the root cause; the adjudication re-ran the reproducer's tests (5 of 7 fail, controls pass, 7 of 7 with the fix) and confirmed it with high confidence. The brief re-ran everything on main a9779ef3c193.
Checks and verdicts365 words
The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real, low" while agreeing on the root cause, finding the inputs within the contract and rating the intended argument weak; a separate re-run of the repro failed 5 of 5.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Bytecode scan, differential sweep of 35 cases (4 STALE cases for this bug), TLA+ counterexample; public-API JUnit 5 of 5 on main (-ea, -da), 100 of 100 over 20 iterations, 10.5.2, 9.12.3, 8.11.4, 7.2.0; zero configuration stale on 10.5.2 and 9.12.3. |
| cross-model review, refute lens | not real, low (procedural) | OpenAI GPT-5.6, reading-only, could not run Java; agreed on the root cause and rated the intended argument weak. A separate run re-ran the repro: 5 of 5 fail. |
| Independent reproducer | real | Own JUnit with doc-id sets fails 5 of 7 on main (-ea, -da), 10.5.2 and 9.12.3; both controls pass; 50 of 50 bug-test runs over 10 iterations; zero configuration 6060 instead of 6059; its fix 7 of 7. |
| Cross-model review, works-as-designed lens | real, medium | OpenAI GPT-5.6, reading-only; intended argument weak. The verifier re-ran the repro: 5 of 5 fail, the fix passes. |
| Intended-behaviour devil's advocate | real (high that it is not intended) | Built the strongest intended case and showed why it fails; its drill-down probe is stale on main (assertions on and off; an always-cache policy and the stock UsageTrackingQueryCachingPolicy), and its MIGRATE.md program on main and 10.5.2; the fix passes. |
| adjudication | confirmed, high | Re-ran the independent reproduction's JUnit with a new seed (-ea, -da): 5 of 7 fail, both controls pass; the independent reproduction's fix 7 of 7. |
| Follow-up: history and security probe | introduced by LUCENE-8042 | Commit-level isolation of the introducing commits and parents; allow/deny-flag probe 3 of 3 fail, scope controls 6 of 6 pass; security later re-evaluated as not security-sensitive. |
| brief re-runs | reproduced on a9779ef3c193 | New 8-test JUnit through gradle and the standalone runner, stock and fixed, main and 10.5.2; the original, independent, drill-down, MIGRATE.md and security probes re-run on a9779ef3c193, stock and fixed. |
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 491 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,538 words
- Registry title
- LongRange/DoubleRange#getQuery(fastMatchQuery, valueSource) reports its Weight as cacheable without asking the fast-match query, so after an in-place doc-values update the query cache keeps returning the old matches
- Area
- facet / range drill-down queries (LongRange and DoubleRange ValueSourceQuery and MultiValueSourceQuery) and the query cache
- Priority
- Low, rank 352: Needs a doc-values fast-match query in the experimental range getQuery plus in-place updates and a query cache across reopens, a combination with no known user.
- Impact class
- silent wrong results
- Impact note
- Applications that filter or drill down with LongRange or DoubleRange#getQuery(fastMatchQuery, valueSource), pass a fast-match query that reads a doc-values field (an experimental API used in an unusual way), update that field in place (IndexWriter#updateNumericDocValue, updateBinaryDocValue or updateDocValues) and keep a query cache across NRT reopens get the matches from before the update: a document that stopped matching the fast match is still returned and one that started matching is missing, until the segment is merged into a new one or the entry is evicted; on 7.2.0-10.6.0 the default IndexSearcher cache is enough (zero-configuration repro executed on 9.12.3 and 10.5.2 with a 12,000-doc segment and the query repeated; 10.6.0 by reading), on main only an application-installed cache (no default cache since GITHUB#14187); the usual points bounding-box fast match is not affected; ordinary scale.
- Summary
- You build a range filter or drill-down with LongRange or DoubleRange#getQuery(fastMatchQuery, valueSource) whose fast-match query reads a doc-values field (for example NumericDocValuesField.newSlowExactQuery("inStock", 1)), run it through a query cache, then change that field for one document in place with IndexWriter#updateNumericDocValue and reopen the reader. You expect the reopened searcher to use the new value, because a query that depends on updated doc values must report itself not cacheable (SegmentCacheable), and the fast-match query alone does. Instead the range query's Weight#isCacheable asks only the value source, the segment keeps its core cache key, and the cache keeps serving the pre-update result (5 hits instead of 4 in the repro) until the segment is merged away or the entry is evicted.
- Introduced
- 276e317e9424 (master, 'LUCENE-8042: Add SegmentCachable interface', by romseygeek, +1 by rmuir; JIRA patch workflow, no GitHub PR; migrated as apache/lucene#9090); branch_7x backport 6e4f9a62e7cc, the commit released in 7.2.0. It replaced the never-cacheable 'getCacheHelper ... return null; // TODO delegate to LongValuesSource?' left by LUCENE-8017 (a886a001a4c) with 'return valueSource.isCacheable(ctx)' and did not consult the fastMatchWeight created in the same createWeight [read: git blame, git log -L and git show in the history clone; exec: commit-level builds of core, queries and facet: the parents faa4293090ae (branch_7x) and 281e84b988a5 (master) are correct, 6e4f9a62e7cc and 276e317e9424 serve the stale count, the fast-match-alone control is correct in all four, and the fix sketch shadowed onto 6e4f9a62e7cc is correct]; the multi-valued overloads copied the same body in 82703757fe05 (main) / 60b499b907d3 (branch_9x), 'LUCENE-10245: Addition of MultiDoubleValues(Source) and MultiLongValues(Source) ...', apache/lucene#543 by gsmiller, first released in 9.1.0 (2017-11-07 (LUCENE-8042 author date; committed 2017-11-10 on master and branch_7x); 2022-01-10 (#543 merged, multi-valued overloads))
- First affected version
- 7.2.0 [exec] (tagged 2017-12-19); the multi-valued getQuery overloads from 9.1.0 [exec] (9.0.0 has no such API: compile error [exec]). 7.1.0 also serves the stale count [exec], but for an older and broader root cause (the query cache then ignored doc-values updates for every query, so the fast-match query alone is stale too), fixed by LUCENE-8017 in 7.2.0
- Affected versions
- every release from 7.2.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed. [exec] single-valued JUnit (2 of 3 fail, the fast-match-alone control passes) on 7.2.0, 7.7.3, 8.0.0, 8.11.4 and 9.0.0 (-da also on 7.2.0 and 8.11.4); the canonical JUnit (4 getQuery overloads plus the isCacheable check, 5 of 5 fail) on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-da also on 9.12.3, 10.5.2 and main); zero-configuration IndexSearcher (default LRUQueryCache and UsageTrackingQueryCachingPolicy, 12,000-doc segment, query repeated) stale on 9.12.3 and 10.5.2, correct on main, which has had no default query cache since GITHUB#14187 (unreleased); zero configuration not executed on 7.x or 8.x. [read] every isCacheable body that exists is unchanged at all 62 release tags from 7.2.0 to 10.5.2 in the history clone (the two single-valued bodies at the 30 tags 7.2.0-9.0.0, all four from 9.1.0) and in the bytecode of the 8.8.2-8.11.4 facet jars; 10.6.0 (released 2026-10-29; branch_10_6) by source only. Not fixed upstream: unchanged on upstream main e1c4184cb983 and branch_10x (2026-10-26)
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal; also reached by code reading, TLA+/TLC
- Upstream and prior reports
- none found (2026-10-26, the prior-report record verdict new: 354 logged queries: a scripted prior-report search, 3 runs, with 32 GitHub issue/PR, 6 commit, 5 downstream-repo, 56 mailing-list (dev@, java-user@, issues@, commits@), 12 JIRA-archive and 16 web queries; 134 read-only GraphQL searches over apache/lucene, Elasticsearch, OpenSearch, Solr, lucene-solr and lucenenet; 56 lists.apache.org queries; 24 JQL queries on LUCENE and SOLR; 10 manual web queries; the changed-file lists of all 411 open apache/lucene PRs; direct reads of the introducing threads; the intended-behaviour check's own searches agree). Not fixed upstream: the four isCacheable bodies are unchanged on upstream main e1c4184cb983 and branch_10x; the only open PR touching the files, #16847, changes maxDoc to maxLeafDoc and not isCacheable. Related, not reports of this bug: #9090 (LUCENE-8042, introduced it; no comment mentions the range classes or the fast match), #543 (LUCENE-10245, copied it; romseygeek's 'possibly we don't need to worry about caching either given how these are used?' and gsmiller's reply 'I think we still need isCacheable though for the query implementations in LongRange and DoubleRange' are about whether the new abstractions need caching support at all, not about stale results), #9065 (LUCENE-8017, the same bug class in FunctionRangeQuery/FunctionMatchQuery, fixed), #6481 (LUCENE-5418, introduced the fast match), #13375 and #13383 (same classes, a speed rewrite), #11098 (LUCENE-10060, DrillSidewaysQuery caching, another bug), #8398 (LUCENE-7344, a comment by dsmiley states the general rule that isCacheable must be false with doc-values-dependent clauses). Gaps: keyword search only; GitHub Discussions and chat channels not searched; GitHub's search rate limit capped one scripted prior-report search (GraphQL search covered the rest); a scripted prior-report search scores list mails on their first 400 characters
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off and 10 derived seeds; the 10.5.2 release jars (assertions on and off). The earlier checks ran on mainea57360912ed, whose facet module and cited core files are identical apart from an unrelatedLeafReadermethod; release runs on 7.1.0, 7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2, and commit-level builds of the introducing commits and their parents. The fix was validated in the scratch worktree: the new test with assertions on and off, the wholelucene/facetmodule with assertions on and off, and:lucene:facet:check -x test.
- Finding record
lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:41 (@lucene.experimental), :156-175 (createWeight builds fastMatchWeight and iterates its scorer), :196-198 and :303-305 (isCacheable returns valueSource(s).isCacheable(ctx) only), :319 and :335 ('@param fastMatchQuery a query to use as a filter'); DoubleRange.java:42 (@lucene.experimental), :209-211 and :316-318 (same isCacheable), :332 and :348 (same @param)lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:27-35 ('Objects that depend on doc values should return DocValues#isCacheable ... Updated doc values fields are not suitable for caching'); lucene/core/src/java/org/apache/lucene/index/LeafReader.java:63-68 (the core cache helper may treat readers with different doc values updates as equal); lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816-834 (trusts in.isCacheable(context), then keys on getCoreCacheHelper()); lucene/core/src/java/org/apache/lucene/search/QueryCache.java:26-30 (the cached weight is equivalent when scores are not needed); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84 (DEFAULT_QUERY_CACHE = null on main); lucene/MIGRATE.md:227-235 ('Query caching is now disabled by default', setDefaultQueryCache snippet)lucene/core/src/test/org/apache/lucene/search/TestSegmentCacheables.java:43 and :84 ('after field3 is updated, all composites referring to it should be uncacheable'); lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:2222 (testDocValuesUpdatesDontBreakCache); lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:77 and :101 ('only documents passing the filter are checked'); lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:137-140 (ValueSourceQuery.visit calls visitLeaf only); lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java:114-115 (isCacheable returns predicateValueSource.isCacheable(ctx) only)
- The finding in 30 seconds
lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:153-200 (at a9779ef3c193) (createWeight, fastMatchWeight :156-159, scorer :167-175, two-phase :177-190, isCacheable :195-198), :302-305, :313-314, :319, :335lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:169-188, (at a9779ef3c193) :208-211, :315-318, :332, :348lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816-834, (at a9779ef3c193) :903-921lucene/core/src/java/org/apache/lucene/index/LeafReader.java:66-70; (at a9779ef3c193) core/src/java/org/apache/lucene/search/SegmentCacheable.java:30-32lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:110-111, (at a9779ef3c193) :144-145; core/src/java/org/apache/lucene/document/SortedNumericDocValuesRangeQuery.java:119-121; core/src/java/org/apache/lucene/index/DocValues.java:340-346; core/src/java/org/apache/lucene/search/BooleanWeight.java:273-285lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:976-982 (at a9779ef3c193) (the cache wraps no-score Weights only); BooleanWeight.java:273-280 (BOOLEAN_REWRITE_TERM_COUNT_THRESHOLD)
- Who is affected
a Lucene clone releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:83-99lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84; (at a9779ef3c193) lucene/MIGRATE.md:227-236; core/src/java/org/apache/lucene/search/LRUQueryCache.java:251-253, :278-296lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:134-137; (at a9779ef3c193) facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:1673-1676; facet/src/test (no in-place doc-values update, grep)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:30-32; (at a9779ef3c193) core/src/java/org/apache/lucene/search/QueryCache.java:28-30lucene/core/src/test/org/apache/lucene/search/TestSegmentCacheables.java:43, (at a9779ef3c193) :84; core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:2222lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:273-285; (at a9779ef3c193) core/src/java/org/apache/lucene/search/ConstantScoreQuery.java:193-195; queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:280-282lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:61, (at a9779ef3c193) :211-214; spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java:114-115; facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:77, :101
- Suggested fix
lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:280-282 (at a9779ef3c193)lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:34-35; (at a9779ef3c193) facet/src/java/org/apache/lucene/facet/range/LongRange.java:302-305 (valuesSource)
- Prior reports
read-only gh search and gh api on 2026-10-27 (upstream main 20ced71c1e6e, LongRange.java:196-198, :303-305, DoubleRange.java:209-211, :316-318)
- TestRangeFastMatchQueryCache.java (JUnit test):
repro/LFV-320/src/org/apache/lucene/facet/range/TestRangeFastMatchQueryCache.java(77 lines)
The PNG is the final summary card. It was made in parallel with this doc, and every on-screen fact was checked against the code and executed runs.
assets/LFV-320/bug.png(491 KB)
![Summary card: range getQuery with a doc-values fast match, the query cache serves the matches from before an in-place update. Top left: search 6 on the reopened reader r2 asks LRUQueryCache isCacheable(leaf); the range Weight answers valueSource.isCacheable(ctx) (line 197): price not updated, true; the fast match on inStock was updated (generation 1) and would say false, but is not asked; so the cache hits the entry for (range, core c0) and returns {0, 1, 3}: doc 0 still returned. Top right: the cause, with the code lines in LongRange, DoubleRange, LRUQueryCache and LeafReader. Bottom left: the example, 5 products, doc 0 sells out; search 6 returns [0, 1, 3], expected [1, 3]; the fix (executed): also ask fastMatchWeight.isCacheable(ctx), as BooleanWeight asks its clauses; affected: a fast match that reads a doc-values field updated in place, with a query cache that survives the reopen (the default IndexSearcher cache on 7.2.0-10.5.2, an installed cache on main); not affected: points or postings fast matches such as the bounding box in DistanceFacetsExample. Bottom right: results, expected versus actual: the example (all 4 overloads) [1, 3] versus [0, 1, 3]; doc 2 back in stock instead [0, 1, 2, 3] versus [0, 1, 3]; MIGRATE.md cache with 12k docs 3999 versus 4000 hits; 10.5.2 default searcher with 12k docs 3999 versus 4000 hits; the fast match alone, cached, [1, 3, 4] both, correct; with the fix [1, 3] both, correct.](../assets/LFV-320/bug.png)