All findings · Lucene finding · LFV-947 · composite-verify-query-cacheable-ignores-index-query

CompositeVerifyQuery reports itself cacheable without asking its index query, so a query cache keeps old matches after an in-place doc-values update

CompositeVerifyQuery tells the query cache it is cacheable by asking only its shape check, never the index query it runs. If that index query reads a doc-values flag you update in place, the cached query keeps its old matches: [0, 1, 2] instead of [1, 2, 4] in the repro.

Summary

Priority
Low, rank 354Needs a hand-built CompositeVerifyQuery with a doc-values index query plus in-place updates and a query cache that survives reopens.
Impact
silent wrong results
Since
7.2.0 (LUCENE-8042, 2017)introduced 2017-11-07 · about 8 years
Affected
7.2.0 to 10.6.0 and main (10.6.0 run at its release revision 4ae946e5a9b)
Status
documented
Found by
a model-based cached-vs-uncached harness, then a JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

CompositeVerifyQuery finds candidates with a cheap index query, then checks each one against an exact shape. 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 shape check only.

Background: three pieces

  1. CompositeVerifyQuery(indexQuery, predicate) in spatial-extras runs indexQuery to find candidate documents, then checks each candidate against an exact shape (the predicate). Its constructor is public and accepts any query.
  2. A query cache (LRUQueryCache) stores a query's matching documents per segment. Before it reuses an entry it asks the query's Weight isCacheable(segment). A Weight that reads doc values must say no once those doc values were updated in place.
  3. IndexWriter#updateNumericDocValue changes a doc-values field without rewriting the segment. The reopened segment can keep its cache key (it does in the repro), and then only that isCacheable answer stops the cache from serving old matches.
Summary card for CompositeVerifyQuery and the query cache: the composite's isCacheable asks only the shape predicate and not the doc-values index query, so after an in-place doc-values update and a reopen the cache serves the matches from before the update; expected versus actual results and the one-line fix.
The diagram follows its own executed example on main: a doc-values index query, an in-place update of one document, and the cached composite returning its pre-update matches while an uncached search is correct. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index six shops at points (0,0) to (5,5) with a CompositeSpatialStrategy; shops 0-2 are open (open = 1, a doc-values field). Search new CompositeVerifyQuery("in the area" AND open == 1, exact shape check) through a query cache: [0, 1, 2].
  2. Shop 0 closes and shop 4 opens: two updateNumericDocValue calls on open, then reopen the reader. You expect [1, 2, 4], and an uncached searcher on the new reader returns [1, 2, 4].
  3. Instead the same search through the cache returns [0, 1, 2]. Asked "is your cached result still valid?", the composite checks only its shape field, which did not change, and never asks its index query, which would say no. The in-place update left the segment's cache key unchanged, so the old entry is served.
  4. It lasts until the segment is merged or the entry is evicted or cleared. On 7.2.0-10.5.2 and 10.6.0 the default IndexSearcher cache is enough; main has no default cache, so the application must install one. CompositeSpatialStrategy's own queries are not affected.

Who is affected

Applications that build CompositeVerifyQuery themselves around an index query that reads a doc-values field (an open or visible flag next to the spatial filter), update that field in place, and keep a query cache across reopens: the default IndexSearcher cache on 7.2.0-10.5.2 and 10.6.0, an installed one on main. Rare: no in-tree code builds such a query.

Mechanism, terms and numbers936 words

CompositeVerifyQuery(Query indexQuery, ShapeValuesPredicate predicate) (spatial-extras, public constructor, @lucene.experimental) finds candidates with its index query and verifies each one with the shape predicate. Its Weight iterates the index query's scorer as the approximation of every match, but its isCacheable asks only the predicate (return predicateValueSource.isCacheable(ctx);, CompositeVerifyQuery.java:113-116 on main a9779ef3c193). LRUQueryCache trusts Weight#isCacheable and keys entries on the segment's core cache key, which a doc-values update may leave unchanged (LeafReader allows it, and in the repro it stays the same). So when the index query reads a doc-values field that is updated in place (IndexWriter#updateNumericDocValue or updateBinaryDocValue), the entry cached before the update keeps being served after the reopen: [0, 1, 2] instead of [1, 2, 4] for an "in the area AND open" index query, [0, 1, 2] instead of [1, 2] for a bare newSlowExactQuery, 5025 instead of 5024 hits with the release-default cache settings. The index query on its own is correctly uncacheable on that segment. On 7.2.0-10.5.2 and 10.6.0 (released 2026-10-29) a plain IndexSearcher reproduces it with zero configuration; main has had no default cache since #14187 (unreleased), so there it needs an installed cache. CompositeSpatialStrategy's own queries (prefix-tree index queries) are not affected. Introduced by LUCENE-8042 (276e317e942), which replaced a never-cacheable "TODO delegate to PredicateValueSource?" with the predicate check and left out the index-query Weight built in the same method. The fix ANDs indexQueryWeight.isCacheable(ctx) in.

Terms

  • CompositeVerifyQuery: "A Query that considers an 'indexQuery' to have approximate results, and a follow-on ShapeValuesSource is called to verify each hit" (CompositeVerifyQuery.java:33-35; the field comment at :41 says "approximation (matches more than needed)"). Public constructor (:44), @lucene.experimental (:37). CompositeSpatialStrategy builds it around a prefix-tree (RPT) query; applications can build it around any query.
  • Query cache: LRUQueryCache stores the matching documents of a query per segment, keyed on the query and the segment's core cache key. It asks Weight#isCacheable(leaf) before it reads or writes an entry (LRUQueryCache.java:816-834 and :903-921).
  • In-place doc-values update: IndexWriter#updateNumericDocValue and updateBinaryDocValue change a doc-values-only 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); the test asserts it does. 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).

The bug

  1. createWeight builds the index query's Weight once (CompositeVerifyQuery.java:95-97) and feeds its scorer's iterator to the predicate's two-phase iterator (:102-108): every match is an index-query match that the predicate confirmed.
  2. isCacheable returns predicateValueSource.isCacheable(ctx) and nothing else (:113-116).
  3. A doc-values index query such as NumericDocValuesField.newSlowExactQuery("open", 1) reports DocValues.isCacheable(ctx, "open") (SortedNumericDocValuesRangeQuery.java:119-121), false once the field was updated in that segment (DocValues.java:339-346); a small BooleanQuery ANDs its clauses' answers (BooleanWeight.java:272-285). The composite's Weight never asks it.
  4. After the update and reopen, the cache asks the composite's Weight, gets true, finds the old entry under the unchanged core key and returns it. The index query's Weight is not consulted, because the outer entry is served before the composite's scorer is built.

The numbers

Six shops at (i, i), one segment, indexed with a CompositeSpatialStrategy; shops 0-2 open. CompositeVerifyQuery(BooleanQuery(FILTER rpt Intersects area, FILTER open == 1), exact shape Intersects area) returns [0, 1, 2]. After updateNumericDocValue(id:0, open, 0), updateNumericDocValue(id:4, open, 1) and openIfChanged: uncached [1, 2, 4], cached [0, 1, 2]: a closed shop returned, an open one missing. The same flag as a sibling clause outside the composite is correct through the same cache. With the bare index query newSlowExactQuery("v", 1) and doc 0 changed from v=1 to v=2: cached [0, 1, 2] instead of [1, 2]. With the release-default cache settings (LRUQueryCache(1000, 32 MB), which is the releases' min(32 MB, heap/20) for heaps of 640 MB or more, and UsageTrackingQueryCachingPolicy; 10,050 documents, query counted 10 times): 5025 instead of 5024. The composite's isCacheable is true on the updated segment while the index query's is false.

When it happens and when not

  • Needs: an index query that reads a doc-values field, an in-place update of that field, the composite cached on that segment before the update, and a cache that survives the reopen. The composite must run without scores, as a count, a filter clause or a no-score collector does: only then does IndexSearcher#createWeight wrap it in the cache (IndexSearcher.java:976-982).
  • Affected index queries (model-based harness, 14 stale results): newSlowExactQuery, newSlowRangeQuery, a BooleanQuery with such a FILTER clause, FieldExistsQuery on a doc-values field, SerializedDVStrategy.makeQuery after a binary doc-values update.
  • Not affected, by the code: CompositeSpatialStrategy.makeQuery (Intersects builds an IntersectsRPTVerifyQuery, CompositeSpatialStrategy.java:122-134; the other operations wrap an RPT query, :154-155, whose Weight is always cacheable, AbstractPrefixTreeQuery.java:107-108, or a TermQuery for a point shape on a points-only grid, also always cacheable, RecursivePrefixTreeStrategy.java:185-187, 216-229, TermQuery.java:192-194).
  • No mismatch observed in the harness (its 9 index-query kinds x 9 index operations) for TermQuery, IntPoint range and kNN index queries, an update of the predicate's own shape field, deletes, new segments and updateDocument.
  • The stale entry ends when the segment is merged into a new one, the entry is evicted, the application clears the cache (LRUQueryCache.java:458), or searches switch to another cache; LRUQueryCache#close keeps the entries and only stops an optional cleanup thread (:264-275). The duration in a running application was not measured.
Exposure and likelihood288 words
  • Intended public use. LUCENE-5579 (#6641), which added the class, "Pulled out the inner Query instances to live in the 'composite' package, so that they might be used by users who want to build with these constructs", after asking how users "can put other query/filters in-between RPT and the SerializedDVStrategy". An index query that ANDs the prefix-tree query with a doc-values flag fits that description.
  • 7.2.0 to 10.5.2 and 10.6.0: the default configuration. IndexSearcher has a JVM-wide default LRUQueryCache(1000, min(32 MB, heap/20)) with UsageTrackingQueryCachingPolicy (10.5.2 IndexSearcher.java:83-99). It caches only segments of at least 10,000 documents that are large relative to the index (LRUQueryCache.java:251-253, 278-296), and a query only after repeated use. One 10,050-document segment and the query counted 10 times reproduce it with no cache code at all on 7.2.0, 8.11.4, 9.12.3, 10.5.2 and branch_10_6 (5025 instead of 5024); an independent program with 12,000 documents gives 6000 instead of 5999 on 10.5.2 and branch_10_6.
  • main: opt-in. There is no default cache since #14187 (IndexSearcher.java:84, not yet released); MIGRATE.md:227-236 shows how to turn it back on.
  • Effect. Silent wrong hit lists and counts for the documents whose field changed, in both directions; 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: six documents with a cache that accepts every segment; one segment of about 10,000 documents with the default cache (LRUQueryCache.java:251-253).
  • How likely: rare. The only in-tree producer, CompositeSpatialStrategy, passes prefix-tree queries, which cannot change in place; no Lucene query parser builds a CompositeVerifyQuery; no spatial-extras test touches caching; no outside report was found in the documented searches.

Minimal repro

The test shows that a CompositeVerifyQuery whose index query reads doc values (NumericDocValuesField.newSlowExactQuery("v", 1)) stays cached across an in-place updateNumericDocValue and reopen, because its Weight#isCacheable asks only the shape predicate.

TestCompositeVerifyQueryCacheability.java (JUnit test)88 lines
/*
 * 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.spatial.composite;

import java.io.IOException;
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.Query;
import org.apache.lucene.search.QueryCachingPolicy;
import org.apache.lucene.spatial.query.SpatialOperation;
import org.apache.lucene.spatial.serialized.SerializedDVStrategy;
import org.apache.lucene.spatial.util.ShapeValuesPredicate;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;

public class TestCompositeVerifyQueryCacheability extends LuceneTestCase {

  public void testCachedCountAfterDocValuesUpdate() throws IOException {
    SpatialContext ctx = SpatialContext.GEO;
    SerializedDVStrategy shapes = new SerializedDVStrategy(ctx, "shape");
    Query query =
        new CompositeVerifyQuery(
            NumericDocValuesField.newSlowExactQuery("v", 1),
            new ShapeValuesPredicate(
                shapes.makeShapeValueSource(),
                SpatialOperation.Intersects,
                ctx.getShapeFactory().rect(-10, 10, -10, 10)));
    LRUQueryCache cache = new LRUQueryCache(100, 1 << 20, leaf -> true, Float.POSITIVE_INFINITY);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new StringField("id", "0", Field.Store.NO));
      doc.add(new NumericDocValuesField("v", 1));
      for (Field f : shapes.createIndexableFields(ctx.getShapeFactory().pointXY(0, 0))) {
        doc.add(f);
      }
      w.addDocument(doc);
      try (DirectoryReader before = DirectoryReader.open(w)) {
        cachingSearcher(before, cache).count(query);
        w.updateNumericDocValue(new Term("id", "0"), "v", 2);
        try (DirectoryReader after = DirectoryReader.openIfChanged(before, w)) {
          // expected 0 hits (doc 0 now has v=2); actual 1: the pre-update cache entry is served
          assertEquals(0, cachingSearcher(after, cache).count(query));
        }
      }
    }
  }

  private static IndexSearcher cachingSearcher(DirectoryReader reader, LRUQueryCache cache) {
    IndexSearcher searcher = new IndexSearcher(reader);
    searcher.setQueryCache(cache);
    searcher.setQueryCachingPolicy(
        new QueryCachingPolicy() {
          @Override
          public void onUse(Query query) {}

          @Override
          public boolean shouldCache(Query query) {
            return true;
          }
        });
    return searcher;
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-947/, 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/spatial-extras/build/libs/lucene-spatial-extras-$V.jar
CP=$CP:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$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.spatial.composite.TestCompositeVerifyQueryCacheability
# the module(s) spatial-extras also need their third-party runtime jars
Expected vs actual
Expected count 0 after doc 0's v changes from 1 to 2; actual 1 (the pre-update cache entry), with -ea and -da (expected:<0> but was:<1>).

All files of this repro: repro/LFV-947/

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.spatial.composite.TestCompositeVerifyQueryCacheability.testCachedCountAfterDocValuesUpdate(TestCompositeVerifyQueryCacheability.java:67)

Why it looks unintended

  • The cache contract says doc-values readers must report updated fields as not cacheable (SegmentCacheable.java:30-32); a core test: "all composites referring to it should be uncacheable".
  • Core wrappers that run an inner query (FilterWeight, ConstantScoreQuery, FunctionScoreQuery) include its answer; LUCENE-8042 left this one out.
  • Nothing restricts the index query; LUCENE-5579 made the inner queries public for users' own compositions.
The full argument276 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 collects and counts without scores.
  • Upstream test. TestSegmentCacheables.java:84-92: "after field3 is updated, all composites referring to it should be uncacheable". TestLRUQueryCache.java:2222 (testDocValuesUpdatesDontBreakCache) tests the cache against doc-values updates with a test query whose Weight returns DocValues.isCacheable (DVCacheQuery, :2165, :2213): after the update that segment is no longer cached (:2271-2284).
  • Precedent in core and queries. FilterWeight.java:47-50 (return in.isCacheable(ctx)), ConstantScoreQuery.java:192-195 (innerWeight.isCacheable(ctx)), BooleanWeight.java:272-285 and FunctionScoreQuery.java:279-282 (inner.isCacheable(ctx) && valueSource.isCacheable(ctx)). LUCENE-8042 gave FunctionScoreQuery a value-source-only answer too; LUCENE-7736 (#8787; 2a4dd499bbd on master and 03afeb7766a on the 7.x line, both committed 2017-11-15, in 7.2.0) ANDed its inner Weight in: a precedent for the fix.
  • History. LUCENE-8017 (a886a001a4c) made this Weight never cacheable: return null; // TODO delegate to PredicateValueSource?. LUCENE-8042 (276e317e942, by romseygeek, +1 by rmuir, 107 files, 7.2.0) resolved the TODO by delegating to the predicate only; the indexQueryWeight was already in the same method. LUCENE-8017 had already made ConstantScoreQuery, FilterWeight and BooleanWeight delegate to their inner Weights' cache helpers, and LUCENE-8042 kept that delegation in isCacheable. No comment on #9065 or #9090 mentions CompositeVerifyQuery or its index query.
  • Docs. No javadoc, package doc, CHANGES or MIGRATE entry says the index query must be segment-immutable. @lucene.experimental is about API stability, not correctness; the sibling IntersectsRPTVerifyQuery is @lucene.internal.
The devil's-advocate check256 words

The intended-behaviour checker and a reviewer from another model family (OpenAI GPT-5.6) built the strongest case: (a) the composite package was built for RPT plus serialized shapes, and the only in-tree producer passes an RPT postings query, for which asking the predicate only is exactly right; (b) the sibling IntersectsRPTVerifyQuery uses the same predicate-only rule (IntersectsRPTVerifyQuery.java:157-158), and IndexOrDocValuesQuery#isCacheable also asks only one sub-query (IndexOrDocValuesQuery.java:211-214); (c) the class is @lucene.experimental; (d) a user can put the doc-values filter outside the composite as a sibling clause; (e) main has no default cache.

Why it fails: (a) convention is not a contract; the constructor is public, accepts any Query, and LUCENE-5579 contemplated users putting other filters between RPT and the serialized shapes. (b) IntersectsRPTVerifyQuery builds its approximation internally from prefix-tree terms, so predicate-only is correct there; IndexOrDocValuesQuery relies on its documented precondition "Both provided queries must match the same documents" (:61), and the "delegate to the indexQuery" remark on #9065 was about that query; here the two parts are different conditions. (c) Experimental allows API changes, not wrong results. (d) A workaround does not make the wrapping illegal, and nothing tells users to use it. (e) The cache is public, MIGRATE.md shows how to re-enable it, and it is the default in every release since 7.2.0 and on the 10.6 branch. Verdict: not intended: the independent reproducer rated the intended case weak, the second cross-model review moderate and rejected it, the devil's advocate "high that it is not intended".

Suggested fix

Recommended Also ask the index query's Weight (validated)

In CompositeVerifyQuery's Weight, also ask the index query: return indexQueryWeight.isCacheable(ctx) && predicateValueSource.isCacheable(ctx); (one line, the shape of FunctionScoreQuery). The new test passes 5 of 5 and the whole spatial-extras module passes, with assertions on and off; the in-tree prefix-tree composite stays cached.

@Override
public boolean isCacheable(LeafReaderContext ctx) {
  return indexQueryWeight.isCacheable(ctx) && predicateValueSource.isCacheable(ctx);
}
All options and trade-offs3 options · 446 words

One fix was built and validated; two alternatives were not built.

Also ask the index query's Weight (validated) (recommended)

A one-line change in CompositeVerifyQuery.createWeight's anonymous Weight (1 file, +1/-1). It is what FunctionScoreQuery does for its inner query.

@Override
public boolean isCacheable(LeafReaderContext ctx) {
  return indexQueryWeight.isCacheable(ctx) && predicateValueSource.isCacheable(ctx);
}

Trade-offs

  • On a segment where a field the index query reads was updated in place, the composite is no longer cached until that segment is merged away. The index query on its own is already treated that way, so no correct result is lost; caching is lost, though, whenever the index query is not cacheable, for any reason (below).
  • The in-tree path is unchanged: prefix-tree query Weights are always cacheable (AbstractPrefixTreeQuery.java:107-108; so is the TermQuery built for a point on a points-only grid, TermQuery.java:192-194), and the control test shows CompositeSpatialStrategy's composite still served from the cache after an unrelated doc-values update.
  • An index query that is not cacheable for another reason (for example a BooleanQuery with more than 16 clauses, BooleanWeight.java:274-280) now also keeps the composite out of the cache: correct, but a change in what gets cached.
  • One extra isCacheable call, on the index query's Weight, per leaf cacheability check. A "Bug Fixes" CHANGES line would be usual; none is drafted.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle: the new test fails 4 of 5 on the stock tree and passes 5 of 5 with the fix, with assertions on and off; the whole lucene/spatial-extras module (27 suites, among them TestCompositeStrategy, TestSerializedStrategy and TestQueryEqualsHashCode; 122 tests, 0 failures, 5 skipped, 4 of them nightly-only) passes with assertions on and off; :lucene:spatial-extras:check -x test (format, ecjLint, forbiddenApis) passes.
  • Standalone runner with the fix shadowed in: the new test 5 of 5 (-ea, -da); the plain-defaults program on the 10.5.2 jars prints default searcher count=5024, uncached count=5024.
  • Earlier, by the hunt: the finder's 3 tests 3 of 3, the independent reproducer's 3 of 3, the model-based harness 0 mismatches and 0 property violations, upstream TestCompositeStrategy and TestQueryEqualsHashCode (5 seeds, together with the visit fix of LFV-951), and the history probe on the introducing commit 6e4f9a62e7c.
  • Not run: nightly tests, a benchmark.

Document that the index query must be segment-immutable

A javadoc note on the class or its constructor.

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.

Never cache the composite

Return false, as LUCENE-8017 did.

Trade-offs

Correct, but it gives up caching on the in-tree prefix-tree path, where the current answer is right. Not built.

Downstream note

An application that hides documents through a doc-values flag inside this index query keeps showing them after the flag changes, until the stale entry goes; deleted documents stay hidden.

How it was found

A hunt on this class's cache and visitor contracts started from a planning probe, then a model-based differential harness: 162 scenarios comparing cached and uncached search with a reference model found 14 stale results, all for index queries that read doc values after an in-place update, and 0 with the fix. A public-API JUnit confirmed it.

The method in full201 words

Method: semi-formal (the hunt's hunt unit for CompositeVerifyQuery's cache and visitor contracts, 2026-10-28, main a9779ef3c193).

  • Lead. A planning probe from a cross-model ideas pack on committer contracts showed the wrapped query returning [0, 1, 2] where the uncached search returns [1, 2]; the finder re-read its outputs (a planning-stage reviewer had re-run it) and reduced it to a JUnit.
  • Model-based harness. Reference semantics written from the class javadoc (live documents matched by the index query AND the predicate in the current state), compared with the cached search, the uncached search and the structural property "the composite is cacheable only if its index query is" on every leaf: 9 index-query kinds x 9 index operations x 2 search APIs = 162 scenarios, 324 hit-set checks, 99 property checks. Result on the stock jars (main with assertions on and off, 10.5.2): 14 stale cached results and 8 property violations, all for doc-values index queries after an in-place doc-values update; the model agreed with the uncached search in all 162 post-operation checks. With the fix: 0 and 0.
  • JUnit. The scenario reduced to a public-API test: 3 of 3 fail with assertions on and off, 30 of 30 over 10 random seeds.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-28, re-checked): two runs of 51 queries over GitHub issues, PRs and commits, downstream repositories, the mailing lists, the JIRA archive and the web, plus manual GitHub searches and the changed files of all 414 open Lucene PRs. The only issue naming the class, #7338, is unrelated.

Searches6 searches

Related, not reports of this bug: #9090 (LUCENE-8042), which introduced the line, with no comment about the index query; #9065 (LUCENE-8017), the origin of the doc-values cacheability contract, which left the TODO; #8787 (LUCENE-7736), which fixed the same pattern in FunctionScoreQuery; #6641 (LUCENE-5579), which introduced the class and states its public use; #7338 (LUCENE-6276, matchCost), the only issue naming the class; SOLR-7379 and SOLR-10038, about Solr's own geometry cache. No open PR touches CompositeVerifyQuery.java. The hunt's own published LFV-320 page mentions this site in three sentences; it is the hunt's, so it is not a prior report. Gaps: keyword search only; GitHub Discussions and chat channels were not searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits and downstream repositories2026-10-28two runs of a scripted prior-report search (16 issue/PR, 3 commit, 2 downstream queries each) and about 30 manual searches over apache/lucene, apache/lucene-solr, apache/solr, apache/lucenenet, Elasticsearch and OpenSearch; the titles of all 93 apache/lucene items updated since 2026-10-26; code search for the fixno report; only the related items below
Open PRs2026-10-28the changed files of all 414 open apache/lucene PRsnone touches CompositeVerifyQuery.java; #709, #13948, #16757 and #16847 touch the package or LRUQueryCache, not this isCacheable
Mailing lists2026-10-2820 tool queries per run over dev@, java-user@, issues@ and commits@; 10 list-archive queries over the Lucene and Solr listsno report
JIRA archive2026-10-284 tool queries per run and 5 JQL queries over LUCENE and SOLR; the migrated issues of LUCENE-5579, 8017 and 8042 readno report
Web2026-10-286 tool queries per run and 8 manual queriesno report
GitHub (re-check)2026-10-28search repo:apache/lucene CompositeVerifyQuery; isCacheable and spatial-extras items updated since 2026-10-27; the file's latest upstream commitonly #7338; nothing new; file unchanged since d0d2aa274fb (2024-06-06), upstream main 645d4fe14a2

History

Introduced in 7.2.0 by LUCENE-8042 (276e317e942, 2017), which replaced a never-cacheable "TODO delegate to PredicateValueSource?" with a predicate-only answer. Executed: the introducing commits are stale and their parents correct; every executed release from 7.2.0 to 10.5.2, 10.6.0 (run at its release revision 4ae946e5a9b) and main are stale.

Versions tested8 rows

Introduced by 276e317e942 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 6e4f9a62e7c. It replaced LUCENE-8017's never-cacheable getCacheHelper (return null; // TODO delegate to PredicateValueSource?, a886a001a4c, four days earlier) with predicateValueSource.isCacheable(ctx). CompositeVerifyQuery itself dates from 5.2.0 (LUCENE-5579, #6641).

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 index query alone is stale too; LUCENE-8017 fixed that in 7.2.0. The isCacheable line is present at all 62 release tags of the history clone (7.2.0-8.8.1, 9.0.0-10.5.2), in the bytecode of the 7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 jars, on branch_10_6 4ae946e5a9b and on upstream main 645d4fe14a2. Releases before 7.1.0 were not executed.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 4 of 5 fail (gradle and the standalone runner, assertions on and off; 10 iterations); no default cache, so the zero-configuration program is correct
branch_10_6 4ae946e5a9b (10.6.0 RC1, released 2026-10-29) affectedthe finder's test 3 of 3 (assertions on and off); zero configuration 5025 instead of 5024
10.5.2 affectedthe brief's test 4 of 5 (assertions on and off); zero configuration 5025 instead of 5024
7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedthe history probe 5025 instead of 5024, the index query alone correct; zero configuration stale on 7.2.0, 8.11.4 and 9.12.3
7.1.0 older root causestale, but the index query alone too: the pre-7.2.0 cache ignored doc-values updates for every query
a886a001a4c / 6f522dd66fe (LUCENE-8017) and 281e84b988a / faa4293090a (parents of LUCENE-8042) commit isolationcorrect: the composite was never cached
276e317e942 / 6e4f9a62e7c (LUCENE-8042) commit isolationstale (5025 instead of 5024); the index query alone correct; the fix shadowed onto 6e4f9a62e7c correct
main + fix not affectedthe brief's test 5 of 5; the whole lucene/spatial-extras module, 27 suites and 122 tests, 0 failed (assertions on and off)

Independent checks

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 tests and confirmed it with high confidence. The brief re-ran everything on main a9779ef3c193 and validated the fix with gradle.

Checks and verdicts364 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 and rating the intended argument weak; a separate re-run of the repro failed 3 of 3. An executing cross-model review is still open.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateModel-based harness: 14 stale results and 8 property violations, 0 with the fix. Public-API JUnit 3 of 3 on main (-ea, -da), 30 of 30 over 10 seeds, branch_10_6, 10.5.2, 9.12.3; zero configuration stale on 10.5.2 and branch_10_6, correct on main.
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: 3 of 3 fail.
Independent reproducer realOwn JUnit outside org.apache.lucene fails 3 of 3 on main (-ea, -da; 30 of 30 over 10 seeds), 10.5.2, 9.12.3, branch_10_6: [0, 1, 2] instead of [1, 2, 5], count 3 instead of 2; its in-test controls pass; zero configuration 6000 instead of 5999; its fix 3 of 3.
Cross-model review, works-as-designed lens real, mediumOpenAI GPT-5.6, reading-only; rated the intended argument moderate and rejected it. A separate run re-ran the repro: 3 of 3 fail on main and 10.5.2, 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; re-runs fail 3 of 3 on main (-ea, -da).
adjudication confirmed, highRe-ran the independent JUnit with new seeds (-ea, -da), 3 of 3 fail, and the finder's, 3 of 3; the independent fix 3 of 3 pass.
Follow-up: history, prior art, security introduced by LUCENE-8042; new; not security-sensitiveCommit-level isolation on master and branch_7x; release matrix 7.1.0 to main; zero-configuration runs.
brief re-runs reproduced on a9779ef3c193New 5-test JUnit through gradle and the standalone runner, stock and fixed, main and 10.5.2; the spatial-extras module with the fix; the zero-configuration program on 10.5.2, stock and fixed.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,457 words

Finding record

LFV-947, snapshot of 2026-11-06

Registry title
CompositeVerifyQuery's Weight reports itself cacheable from its shape predicate alone and ignores the index query it runs, so after an in-place doc-values update a query cache keeps returning the old matches
Area
spatial-extras / composite: CompositeVerifyQuery (Weight#isCacheable) and the query cache
Priority
Low, rank 354: Needs a hand-built CompositeVerifyQuery with a doc-values index query plus in-place updates and a query cache that survives reopens.
Impact class
silent wrong results
Impact note
Applications that build a CompositeVerifyQuery with its public constructor around an index query that reads numeric or binary doc values (for example NumericDocValuesField.newSlowExactQuery, or a BooleanQuery with such a FILTER clause next to an RPT or term clause), update that field in place (IndexWriter#updateNumericDocValue or updateBinaryDocValue) and keep a query cache across reopens get the matches from before the update, with no error: a document that stopped matching is still returned, one that started matching is missing and counts are off, until the segment is merged away or the entry is evicted; on 7.2.0-10.5.2 and 10.6.0 (released 2026-10-29) the default IndexSearcher cache is enough (zero-configuration repro executed with a segment of 10,000+ documents and the query repeated), on main only an application-installed cache (no default cache since #14187); CompositeSpatialStrategy's own queries (an RPT postings index query) are not affected; ordinary scale.
Summary
You wrap an index query that reads a doc-values field, for example NumericDocValuesField.newSlowExactQuery("v", 1), in new CompositeVerifyQuery(indexQuery, shapePredicate), 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 see the new value, because a query that depends on updated doc values must report itself not cacheable (SegmentCacheable), and the index query alone does. Instead CompositeVerifyQuery's Weight#isCacheable asks only the shape predicate, the segment keeps its core cache key, and the cache keeps serving the pre-update result ([0, 1, 2] instead of [1, 2] in the repro; 5025 instead of 5024 hits with the release-default cache settings).
Introduced
276e317e9424252d89df7596851c7cd3559d79b1 (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 6e4f9a62e7cc221dcb49788ab683c87f764f2f4a, the commit released in 7.2.0. It replaced the never-cacheable 'getCacheHelper ... return null; // TODO delegate to PredicateValueSource?' that LUCENE-8017 (a886a001a4c master / 6f522dd66fe branch_7x, #9065) had added four days earlier with 'return predicateValueSource.isCacheable(ctx)', and did not consult the indexQueryWeight built in the same createWeight [read: git blame and git log -L in the history clone; exec: commit-level builds of core, spatial3d and spatial-extras: the parents of LUCENE-8017 show an older framework-wide staleness (the index query alone is stale too), LUCENE-8017 and the parents of LUCENE-8042 (281e84b988a master, faa4293090a branch_7x) never cache the composite, 276e317e942 and 6e4f9a62e7c serve the stale count while the index-query-alone control is correct, and the fix shadowed onto 6e4f9a62e7c is correct] (2017-11-07 (author date of both commits; committed 2017-11-10 on master and branch_7x))
First affected version
7.2.0 [exec] (tagged 2017-12-19). 7.1.0 also serves a stale count [exec], but for an older, framework-wide root cause (the query cache then had no per-Weight cacheability, so the index query alone is stale too), fixed by LUCENE-8017 in 7.2.0; CompositeVerifyQuery itself exists since 5.2.0 (LUCENE-5579)
Affected versions
every release from 7.2.0 to 10.5.2, 10.6.0 (released 2026-10-29) and main. [exec] history probe (explicit LRUQueryCache, 10,050-document segment) affected on 7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, the branch_10_6 head 4ae946e5a9b and main a9779ef3c193 (-da also on 7.2.0, 10.5.2, branch_10_6 and main), with the index-query-alone control correct on each; the candidate JUnit fails 3 of 3 on main (-ea and -da; 30 of 30 over 10 random seeds), branch_10_6 4ae946e5a9b (-ea and -da), 10.5.2, 10.0.0 and 9.12.3; zero configuration (plain new IndexSearcher, default LRUQueryCache and UsageTrackingQueryCachingPolicy, query repeated) is stale on 7.2.0, 8.11.4, 9.12.3, 10.5.2 and branch_10_6 and correct on main, which has had no default query cache since #14187 (unreleased). [read] the isCacheable line is present at all 62 release tags of the history clone (7.2.0-8.8.1, 9.0.0-10.5.2) and in the bytecode of the 8.11.4 jar (8.8.2-8.11.x were tagged in the separate lucene-solr repository). Not fixed upstream: CompositeVerifyQuery.java last changed by d0d2aa274fb (2024-06-06, #13440); upstream main head 645d4fe14a2 (2026-10-28) and branch_10_6 4ae946e5a9b still carry the line. Some branch_10_6 runs combined the branch's core jars with spatial-extras classes whose composite/, serialized/ and util/ sources are identical between 10.5.2 and the branch head (recorded by the independent reproduction and the finder). All runs on Linux aarch64 with JDK 25.
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
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only): two scripted prior-report searches (the follow-up's and the finder's, 51 queries each: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries; 375 and 1,354 distinct hits, 0 high), manual GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, apache/lucenenet, Elasticsearch and OpenSearch (including the titles of all 93 apache/lucene items updated since 2026-10-26), GitHub code search (no fork or downstream copy carries the fix), the changed-file lists of all 414 open apache/lucene PRs, 5 JQL queries on LUCENE and SOLR, Lucene and Solr mailing-list searches, and 8 web queries; 6 first attempts hit GitHub's search rate limit and were retried successfully. The only apache/lucene issue that names CompositeVerifyQuery is #7338 (LUCENE-6276, matchCost; unrelated). Related, not reports: #9090 (LUCENE-8042, introduced the line; body and all comments read, none mentions the index query), #9065 (LUCENE-8017, origin of the doc-values cacheability contract; left the TODO), #8787 (LUCENE-7736, fixed the identical pattern in FunctionScoreQuery), #6641 (LUCENE-5579, introduced the class and states its public use). Not fixed: no open PR touches CompositeVerifyQuery.java (#709, #13948, #16847 and #16757 touch the package or LRUQueryCache but change no isCacheable line). Refresh 2026-10-28 (GitHub search and REST API, read-only): 'repo:apache/lucene CompositeVerifyQuery' still finds only #7338; no apache/lucene issue or PR mentioning isCacheable or spatial-extras was updated since 2026-10-27; no new commit on the file (last d0d2aa274fb, 2024-06-06); upstream main head 645d4fe14a2. Gaps: keyword search only; GitHub Discussions and chat channels not searched.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-28) 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). Release runs by the hunt on 7.1.0, 7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and the branch_10_6 head 4ae946e5a9b (some of those runs used the branch's core jars with spatial-extras classes whose composite/, serialized/ and util/ sources are identical to 10.5.2), and commit-level builds of LUCENE-8017, LUCENE-8042 and their parents. The fix was validated in the scratch worktree: the new test with assertions on and off, the whole lucene/spatial-extras module with assertions on and off, and :lucene:spatial-extras:check -x test.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java:33-37 (class javadoc, @lucene.experimental), :40 ('approximation (matches more than needed)'), :44 (public constructor), :95-97 (indexQueryWeight), :101-109 (its scorer is the approximation of every match), :113-116 (isCacheable returns predicateValueSource.isCacheable(ctx) only); lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:27-35; lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816 and :903 (the cache trusts in.isCacheable); lucene/core/src/java/org/apache/lucene/search/FilterWeight.java:48-49, ConstantScoreQuery.java:193-194, lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:280-281 (wrappers delegate to or AND in the inner weight); lucene/core/src/test/org/apache/lucene/search/TestSegmentCacheables.java:84-92 ('after field3 is updated, all composites referring to it should be uncacheable'); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84 (DEFAULT_QUERY_CACHE = null on main) [read by the registration check]
The finding in 30 seconds
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java:33-37 (at a9779ef3c193) (javadoc, @lucene.experimental), :41, :44, :95-97 (indexQueryWeight), :101-111 (scorerSupplier), :113-116 (isCacheable)
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816-834, (at a9779ef3c193) :903-921, :264-275 (close), :458 (clear); core/src/java/org/apache/lucene/index/LeafReader.java:66-70; core/src/java/org/apache/lucene/search/SegmentCacheable.java:30-32
  • lucene/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:339-346; core/src/java/org/apache/lucene/search/BooleanWeight.java:272-285; core/src/java/org/apache/lucene/search/IndexSearcher.java:976-982
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:122-134, (at a9779ef3c193) :154-155; spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:185-187, :216-229; core/src/java/org/apache/lucene/search/TermQuery.java:192-194; spatial-extras/src/java/org/apache/lucene/spatial/prefix/AbstractPrefixTreeQuery.java:107-108
Who is affected
  • a Lucene clone releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:83-99
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84; (at a9779ef3c193) lucene/MIGRATE.md:227-236; core/src/java/org/apache/lucene/search/LRUQueryCache.java:251-253, :278-296
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-30
  • lucene/core/src/test/org/apache/lucene/search/TestSegmentCacheables.java:84-92; (at a9779ef3c193) core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:2165, :2213, :2222, :2271-2284
  • lucene/core/src/java/org/apache/lucene/search/FilterWeight.java:47-50; (at a9779ef3c193) core/src/java/org/apache/lucene/search/ConstantScoreQuery.java:192-195; core/src/java/org/apache/lucene/search/BooleanWeight.java:272-285; queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:279-282
  • lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:61, (at a9779ef3c193) :211-214; spatial-extras/src/java/org/apache/lucene/spatial/composite/IntersectsRPTVerifyQuery.java:46 (@lucene.internal), :157-158
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:279-282; (at a9779ef3c193) spatial-extras/src/java/org/apache/lucene/spatial/prefix/AbstractPrefixTreeQuery.java:107-108; core/src/java/org/apache/lucene/search/BooleanWeight.java:274-280; core/src/java/org/apache/lucene/search/AbstractMultiTermQueryConstantScoreWrapper.java:43 (threshold 16)
Prior reports
  • read-only gh api on 2026-10-28: search/issues 'repo:apache/lucene CompositeVerifyQuery' (1: #7338), 'repo:apache/lucene isCacheable updated:>=2026-10-27' (0), 'repo:apache/lucene spatial-extras updated:>=2026-10-27' (0); commits?path=.../CompositeVerifyQuery.java (d0d2aa274fb); commits?sha=main (645d4fe14a2)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

It is made in parallel with this doc, and every on-screen value comes from the code or the diagram's executed probe.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.