All findings · Lucene finding · LFV-567 · function-query-context-map-written-by-concurrent-slices

scale() writes to the context map that all slices of an executor search share: NullPointerException or silently different scores

On an IndexSearcher with an executor, a function query with scale() and about 20 query() sources can silently return different scores or throw NullPointerException: all slices share one context map, and scale()'s write during the search makes it grow while others read it. The repro fails 20 of 20 runs.

Summary

Priority
Low, rank 700With an executor search a FunctionQuery whose scale() grows the shared context map throws a NullPointerException or, with a non-default Similarity, silently gives different scores; the trigger is contrived, so it goes with the concurrent-search crash row LFV-125.
Impact
silent wrong results
Since
4.0.0-ALPHA (2012, LUCENE-3249)introduced 2011-06-28 · over 15 years
Affected
4.0.0-ALPHA through 10.6.0 and main
Status
documented
Found by
a bytecode scan, then a bounded-exhaustive model of IdentityHashMap growth

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

A function query's Weight keeps one small map, the context, for the whole search, and all slices of an executor search use it. scale() fills in its min/max entry lazily, during the search, from whichever slice thread gets there first.

Function queries and their context map in one minute

  1. FunctionQuery (module lucene/queries) scores every document with a function built from ValueSources, for example sum(scale(num, 0, 1), query(body:w0)). scale(num, 0, 1) maps a field's values onto the range 0..1 using the min and max over the whole index; query() uses another query's score.
  2. When the search starts, createWeight makes one context map (an IdentityHashMap) and each source may store per-search data in it: query() stores its Weight, and the map always holds "searcher". For each segment, getValues then reads that data.
  3. new IndexSearcher(reader, executor) groups the segments into slices that run on different threads. They all use the same Weight, so the same map.
Diagram of the bug: several slices of one executor search point at the same context map with 21 entries ("searcher" and 20 query() weights). One slice's scale() adds a 22nd entry during the search, which makes the map copy its entries into a bigger table, emptying each old slot as it goes; while it copies, another slice's query(body:w0) looks up its weight and "searcher", finds both emptied, and rebuilds the weight with the default Similarity, so its scores differ from a search without an executor. The numbers are from this doc's test: gradle search 22 returned the default-Similarity top 10, and 20 of 20 runs failed.
The slices of one executor search share one context map. It holds 21 entries after createWeight; scale() adds the 22nd from a slice thread during the search, the map copies itself into a bigger table, and meanwhile another slice's query() finds its weight and "searcher" missing and scores with the default Similarity. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 1,600 small documents in 32 segments. Search FunctionQuery(sum(scale(num, 0, 1), query(body:w0), ..., query(body:w19))) for the top 10 with BM25Similarity(1.8, 0.3) on new IndexSearcher(reader, executor): 8 threads, 7 slices.
  2. You expect what the same search gives without an executor, every time: doc 612 first with score 4.680546. IndexSearcher is documented as completely thread safe.
  3. Instead, within the first 70 of 1,000 searches, one returns other scores and another order, with no error. In the gradle run doc 1271 is first with 5.1422386 and doc 612 has 5.1421137: exactly what the default BM25Similarity() gives, so the configured Similarity was silently dropped.
  4. Why: the query's Weight has one context map for all slices. After createWeight it holds 21 entries ("searcher" and 20 query() weights). scale() adds the 22nd from a slice thread during the search; the 22nd entry makes the map copy itself into a bigger table, and meanwhile other slices find "searcher" and their weights missing. A query() that misses both its weight and "searcher" then rebuilds its weight on a new searcher with the default Similarity.
  5. Functions that also use maxdoc(), idf(), docfreq(), norm() or tf() can crash instead: NullPointerException ("searcher" is null; seen in earlier tests). With 10 query() sources the map never grows, and all 5 control runs were right. Every release since 4.0.0-ALPHA (2012) is affected.

Who is affected

Applications that search with an executor (opt-in) and use FunctionQuery, FunctionRangeQuery or a ValueSource sort whose function has scale() plus about 20 query(), totaltermfreq() or sumtotaltermfreq() sources, so that scale()'s entry is the map's 22nd or 43rd (or a later growth point). Searchers without an executor are unaffected, and a 10-source function passed every control run. Every release since 4.0.0-ALPHA.

Mechanism, terms and numbers694 words

FunctionQuery, FunctionRangeQuery and ValueSource sort fields create one context map per search (ValueSource.newContext, an IdentityHashMap documented as "non-threadsafe") and hand it to every slice of an executor search. ScaleFloatFunction stores nothing in createWeight; it computes the whole-index min/max on first use in getValues and puts it into that shared map from a slice thread. When that put makes the map grow (the 22nd, 43rd or a later growth point), the other slices can briefly find entries missing: a query() that misses both its weight and "searcher" rebuilds it with the default Similarity, so a searcher whose Similarity scores differently silently gets different scores and top hits, and sources that need "searcher" (maxdoc(), idf(), ...) throw NullPointerException. In the repro (1,600 documents, 7 slices, sum(scale(num), 20 x query()), BM25Similarity(1.8, 0.3)), 20 of 20 runs return a different top 10 within the first 70 searches. Every release since 4.0.0-ALPHA is affected; the fix computes the scale in createWeight.

The code (main 5adff78bb168, lucene/queries/src/java/org/apache/lucene/queries/function/)

// ValueSource.java
/** Returns a new non-threadsafe context map. */                                 // :73
public static Map<Object, Object> newContext(IndexSearcher searcher) {
  Map<Object, Object> context = new IdentityHashMap<>();                         // :75, default capacity 32
  context.put("searcher", searcher);                                             // :76
// FunctionQuery.FunctionWeight: one context per Weight, shared by every slice
this.context = ValueSource.newContext(searcher);                                 // :70
func.createWeight(context, searcher);                                            // :71, query() puts its weight (QueryValueSource.java:85)
vals = func.getValues(weight.context, context);                                  // :116, AllScorer, on each slice thread

// valuesource/ScaleFloatFunction.java
ScaleInfo scaleInfo = (ScaleInfo) context.get(ScaleFloatFunction.this);          // :106
if (scaleInfo == null) scaleInfo = createScaleInfo(context, readerContext);      // :107-109, during the search
context.put(ScaleFloatFunction.this, scaleInfo);                                 // :98, a write to the shared map
public void createWeight(...) { source.createWeight(context, searcher); }        // :148-151, nothing stored

// valuesource/QueryValueSource.java, QueryDocValues constructor
Weight w = fcontext == null ? null : (Weight) fcontext.get(vs);                  // :114, may read null
weightSearcher = (IndexSearcher) fcontext.get("searcher");                       // :120, may read null
if (weightSearcher == null) weightSearcher = new IndexSearcher(ReaderUtil.getTopLevelContext(readerContext)); // :121-122, default Similarity
  • FunctionRangeQuery shares its map the same way (FunctionRangeQuery.java:148-149, :154, :170), and so does a sort by ValueSource.getSortField (ValueSource.java:379-382, :434).
  • After createWeight the map in the repro holds 21 entries: "searcher" and the 20 query() weights. The JDK's IdentityHashMap grows its table when an entry would make 3 * size exceed the table length, so at the 22nd entry for the default capacity 32 (table length 64), then at the 43rd, the 86th and so on, as the table doubles (JDK 25 IdentityHashMap.java:160, :456).
  • resize copies the entries one by one into a new array, nulling each old slot as it goes (:489-501), and only then publishes the new table (:502). A get on the old table that meets a nulled slot returns null (:337-349). By reading the code, two slices that resize at the same time could also drop entries for the rest of the search (not observed directly).
  • A missing "searcher" makes MaxDocValueSource throw (MaxDocValueSource.java:49-50; the same read in DocFreqValueSource.java:60, IDFValueSource.java:51, NormValueSource.java:66, TFValueSource.java:54). A query() that misses both its weight and "searcher" rebuilds its weight with the default Similarity and puts it back into the shared map (QueryValueSource.java:125, then :85), where later leaves of the same search find it; a weight lost again on the way throws NullPointerException: ... because "this.weight" is null (QueryValueSource.java:150).
  • ScaleFloatFunction is the only built-in ValueSource that writes the context outside createWeight (the query() fallback above calls createWeight during a search only after an entry went missing). asDoubleValuesSource is not affected: it makes a new context per leaf (ValueSource.java:200).

The numbers

1,600 documents in 32 segments (default slicing: 7 slices), an 8-thread executor, sum(scale(num, 0, 1), query(body:w0..w19)), BM25Similarity(1.8, 0.3), top 10. Sequential: 612=4.680546 1271=4.6782455 1303=4.6594014 .... The executor search differed within the first 70 of 1,000 searches in 20 of 20 runs (10 with assertions on, 10 off); in 9 of them the wrong top 10 equals, value for value, the sequential result with the default BM25Similarity(): 1271=5.1422386 612=5.1421137 1303=5.1220636 .... Other runs mix the two Similarities term by term.

Exposure and likelihood276 words
  • Who is exposed. Applications that use IndexSearcher(IndexReader, Executor) and run a FunctionQuery, FunctionRangeQuery or ValueSource sort whose function contains scale() and enough context-storing sources (query(), totaltermfreq(), sumtotaltermfreq(); "searcher" counts once) that scale()'s entry is the 22nd or 43rd (or a later growth point) of the map: about 20 such sources with one scale(), or 41.
  • Nearby sizes. With several scale() calls the window widens. With 19 query() sources (the put is then the 21st entry, below the growth point) one of five runs also failed, at search 597 of 1,000; that failure was not instrumented and its cause is open.
  • What they see. With a Similarity that scores differently from the default BM25Similarity(), silently different scores and top hits (no exception; this doc's test, 20 of 20 runs). With maxdoc(), idf(), docfreq(), norm() or tf() in the function, a failed search (earlier tests): NullPointerException: Cannot invoke "org.apache.lucene.search.IndexSearcher.getIndexReader()" because "searcher" is null. With the default Similarity, no run showed different scores (5 runs of this function), but one threw.
  • Contained. Only the triggering search is affected; other searches on the same searcher and executor were unaffected (0 of about 17,000 in a probe), nothing is cached and the index is not touched.
  • Not affected. Searchers without an executor; in the controls, a 10-source function (5 of 5 runs pass all 1,000 searches); asDoubleValuesSource.
  • How likely. Narrow: it takes an executor and a function sized to a growth point, and the searches found no report of it since 4.0.0-ALPHA (2012). But a deployment whose ranking function sits at a growth point fails in most runs.

Minimal repro

One JUnit test in lucene/queries: 1,600 documents in 32 segments, an 8-thread executor, sum(scale(num), 20 x query(body:wN)) with BM25Similarity(1.8, 0.3). It compares up to 1,000 executor searches with one sequential search. Concurrency is the bug, so it is probabilistic: 20 of 20 runs failed, at search 1 to 68.

TestScaleFunctionWithExecutor.java (one test, lucene/queries)103 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.queries.function;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.queries.function.valuesource.LongFieldSource;
import org.apache.lucene.queries.function.valuesource.QueryValueSource;
import org.apache.lucene.queries.function.valuesource.ScaleFloatFunction;
import org.apache.lucene.queries.function.valuesource.SumFloatFunction;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.similarities.BM25Similarity;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.NamedThreadFactory;

public class TestScaleFunctionWithExecutor extends LuceneTestCase {

  public void testExecutorSearchMatchesSequentialSearch() throws Exception {
    Random r = new Random(7);
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int segment = 0; segment < 32; segment++) { // 32 segments: 7 slices
          for (int d = 0; d < 50; d++) {
            StringBuilder body = new StringBuilder();
            for (int k = 0, n = 1 + r.nextInt(6); k < n; k++) {
              body.append(" w").append(r.nextInt(24));
            }
            Document doc = new Document();
            doc.add(new TextField("body", body.toString(), Field.Store.NO));
            doc.add(new NumericDocValuesField("num", r.nextInt(1000)));
            w.addDocument(doc);
          }
          w.flush();
        }
      }
      // sum(scale(num, 0, 1), query(body:w0), ..., query(body:w19)): after createWeight the shared
      // context map holds 21 entries ("searcher" and 20 query weights), so the entry scale() puts
      // during the search is the 22nd, and an IdentityHashMap grows when its 22nd entry is added
      List<ValueSource> sources = new ArrayList<>();
      sources.add(new ScaleFloatFunction(new LongFieldSource("num"), 0, 1));
      for (int i = 0; i < 20; i++) {
        sources.add(new QueryValueSource(new TermQuery(new Term("body", "w" + i)), 0f));
      }
      Query query = new FunctionQuery(new SumFloatFunction(sources.toArray(new ValueSource[0])));

      ExecutorService executor = Executors.newFixedThreadPool(8, new NamedThreadFactory("scale"));
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher sequential = new IndexSearcher(reader);
        IndexSearcher concurrent = new IndexSearcher(reader, executor);
        sequential.setSimilarity(new BM25Similarity(1.8f, 0.3f)); // not the default k1 and b
        concurrent.setSimilarity(new BM25Similarity(1.8f, 0.3f));
        String expected = topHits(sequential, query);
        for (int i = 0; i < 1000; i++) {
          assertEquals("search " + i, expected, topHits(concurrent, query));
        }
      } finally {
        executor.shutdown();
        assertTrue(executor.awaitTermination(1, TimeUnit.MINUTES));
      }
    }
  }

  private static String topHits(IndexSearcher searcher, Query query) throws Exception {
    StringBuilder sb = new StringBuilder();
    for (ScoreDoc sd : searcher.search(query, 10).scoreDocs) {
      sb.append(sd.doc).append('=').append(sd.score).append(' ');
    }
    return sb.toString();
  }
}
Command
cd $LUCENE
cp $REPRO/TestScaleFunctionWithExecutor.java lucene/queries/src/test/org/apache/lucene/queries/function/
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.function.TestScaleFunctionWithExecutor -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
Every executor search returns the sequential top 10: 612=4.680546 1271=4.6782455 1303=4.6594014 ....
Observed
ComparisonFailure: search 22 expected:<[612=4.680546 1271=4.6782455 ...]> but was:<[1271=5.1422386 612=5.1421137 ...]> (gradle, seed DEADBEEF, assertions off).

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

More files, environment, full output351 words

One test method, public API only; it sits next to the other function-query tests. The index and the function are fixed (new Random(7)); the test framework still randomizes the codec. It stops at the first executor search whose top 10 differs from the sequential one.

Regression test: this same test. It passes with the fix (gradle, assertions on and off; 10 of 10 standalone runs) and passes :lucene:queries:check -x test. It covers FunctionQuery and the silent-score form only: the FunctionRangeQuery, sort-field and maxdoc() NullPointerException forms rest on the earlier tests (see Independent checks).

Hit rate: on a 32-core aarch64 machine, 20 of 20 standalone runs fail (seeds 5CA1E001..5CA1E010, assertions on and off), the first wrong search between search 1 and search 68; gradle fails too, although in this run Lucene's gradle setup made the test JVM report 1 CPU (it adds -XX:ActiveProcessorCount=1 outside CI when default vectorization is off); the 8 executor threads still run in parallel. On a machine with few cores the race may need more searches to show.

Controls (5 runs each, assertions on; the two source-count controls change one line, the default-Similarity control both setSimilarity lines): 10 query() sources: 5 of 5 pass. 19 sources: 4 of 5 pass, one failed at search 597 (not instrumented; the cause is open). Default BM25Similarity(): no different scores in these 5 runs, but 1 of 5 threw NullPointerException ... "this.weight" is null.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); the same test also fails on the 10.6.0 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64, 32 CPUs

Observed output

TestScaleFunctionWithExecutor > testExecutorSearchMatchesSequentialSearch FAILED
    org.junit.ComparisonFailure: search 22 expected:<[612=4.680546 1271=4.6782455 1303=4.6594014 24=4.650218 1480=4.630164 309=4.6299486 764=4.6039133 348=4.5888166 1060=4.5771465 1527=4.524094] > but was:<[1271=5.1422386 612=5.1421137 1303=5.1220636 24=5.108552 1480=5.0892344 309=5.0861616 764=5.069854 348=5.0580726 1060=5.035736 1527=4.9915533] >
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:AD787DCA0434352F]:0)
        at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:117)
        at app//org.apache.lucene.queries.function.TestScaleFunctionWithExecutor.testExecutorSearchMatchesSequentialSearch(TestScaleFunctionWithExecutor.java:87)
:lucene:queries:test (FAILURE): 1 test, 1  failure

# assertions on (same seed): search 11 returned a mixed result, 1271=5.1422386 612=4.9920917 1060=4.9601364 ...
# supporting probe, sequential only, same index and function:
#   BM25Similarity(1.8, 0.3): 612=4.680546 1271=4.6782455 1303=4.6594014 ...
#   BM25Similarity()        : 1271=5.1422386 612=5.1421137 1303=5.1220636 ...

Why it looks unintended

  • IndexSearcher instances "are completely thread safe" (IndexSearcher.java:71-75); nothing says function queries need a searcher without an executor.
  • ValueSource's convention: sources store weight info in createWeight, and getValues retrieves it (ValueSource.java:66-68); scale() writes in getValues.
  • Lucene's own Weight code shares the map, not the user, and the wrong scores are exactly the default Similarity's.
The full argument177 words
  • Contract. IndexSearcher instances "are completely thread safe, meaning multiple threads can call any of its methods, concurrently" (IndexSearcher.java:71-75); the executor constructor "Runs searches for each segment separately, using the provided Executor" (:194). Nothing says function queries need a searcher without an executor.
  • ValueSource's convention. "Implementations should propagate createWeight to sub-ValueSources which can optionally store weight info in the context. The context object will be passed to getValues() where this info can be retrieved." (ValueSource.java:66-68). ScaleFloatFunction stores nothing in createWeight (:148-151) and writes in getValues (:98, :106-109).
  • Who shares the map. FunctionWeight, FunctionRangeWeight and ValueSourceSortField hand the one map to every slice; the user never sees it.
  • Tests. TestValueSources searches through newSearcher(reader) (TestValueSources.java:201), which randomly adds an executor (LuceneTestCaseParent.java:2563-2583, :2641-2675); its testScale (:593) passes because its functions are small.
  • Effect. The configured Similarity is silently replaced by the default one for some or all of the terms of a search; that is not a documented trade-off of concurrent search.
The devil's-advocate check118 words

Strongest case for "intended": newContext is documented "Returns a new non-threadsafe context map" (ValueSource.java:73); FunctionQuery (FunctionQuery.java:38) and the executor constructor (IndexSearcher.java:199) are @lucene.experimental; and 20 query() sources is an unusual function.

Why it fails: that javadoc warns whoever shares the map, and the sharing is Lucene's own Weight code; experimental status does not make silently different scores acceptable; no limit on function size is documented, and the failures appear at or next to the map's growth points (21 or 42 entries before scale()), not at some size where things degrade. The intended-behaviour verifier and reviews by another model family (OpenAI GPT-5.6, reading-only) rejected this argument. Verdict: not intended, a defect.

Suggested fix

Recommended A: compute the scale in createWeight

Compute the scale in ScaleFloatFunction.createWeight, so getValues only reads the shared map (one import, one parameter type, five lines). Validated: the repro passes with assertions on and off, the 112 function-query tests and the whole lucene/queries suite (513 tests) pass, and :lucene:queries:check -x test passes.

private ScaleInfo createScaleInfo(Map<Object, Object> context, IndexReaderContext readerContext)
    throws IOException { /* unchanged body */ }

@Override
public void createWeight(Map<Object, Object> context, IndexSearcher searcher) throws IOException {
  source.createWeight(context, searcher);
  // The slices of a concurrent search share this context, so compute the scale now: getValues
  // must only read the context, never write to it.
  if (context.get(ScaleFloatFunction.this) == null) {
    createScaleInfo(context, searcher.getTopReaderContext());
  }
}
All options and trade-offs3 options ยท 544 words

Fix A was validated with gradle in a scratch worktree of 5adff78bb168; B was checked earlier with the changed class compiled over the jars in standalone runs; C was not run. A fixes scale(); B also covers custom ValueSources that write the context from getValues. They combine well.

A: compute the scale in createWeight (validated by the checks) (recommended)

ScaleFloatFunction.createWeight computes the min/max right after source.createWeight, on the thread that creates the Weight, and stores it in the context; getValues then only reads it. createScaleInfo takes an IndexReaderContext instead of a LeafReaderContext, so it can be called with searcher.getTopReaderContext(); ReaderUtil.getTopLevelContext walks to the same root as the stock lazy path, so the min/max is unchanged. The lazy path stays for callers that never call createWeight (asDoubleValuesSource, one context per leaf).

private ScaleInfo createScaleInfo(Map<Object, Object> context, IndexReaderContext readerContext)
    throws IOException { /* unchanged body */ }

@Override
public void createWeight(Map<Object, Object> context, IndexSearcher searcher) throws IOException {
  source.createWeight(context, searcher);
  // The slices of a concurrent search share this context, so compute the scale now: getValues
  // must only read the context, never write to it.
  if (context.get(ScaleFloatFunction.this) == null) {
    createScaleInfo(context, searcher.getTopReaderContext());
  }
}

Trade-offs

  • Fixes scale() only: a custom ValueSource that writes the context in getValues stays unsafe (see B).
  • The whole-index scan moves into createWeight: it runs once, on the calling thread, before the slices start (today each slice that misses the entry may scan in parallel), and it also runs when no scorer is ever pulled. TotalTermFreqValueSource.createWeight already loops over all leaves (TotalTermFreqValueSource.java:65-71), an in-tree precedent for the eager scan.
  • No targeted test yet covers a nested scale() or the FunctionRangeQuery and sort-field entry points; the runs cover this test and the existing suites only.

What the checks validated

  • The repro passes with assertions on and off; with the fixed class compiled over the jars in standalone runs, 10 of 10 runs pass (5 on, 5 off).
  • All 15 classes under org.apache.lucene.queries.function, 112 tests including TestValueSources.testScale and the repro, pass; the whole :lucene:queries:test, 513 tests, 5 skipped, passes.
  • :lucene:queries:check -x test passes (google-java-format, ecj lint, forbidden APIs) with the fix and the new test.

B: make the shared context map thread-safe (validated by the checks)

ValueSource.newContext returns Collections.synchronizedMap(new IdentityHashMap<>()). This covers every writer, custom sources included.

Map<Object, Object> context = Collections.synchronizedMap(new IdentityHashMap<>());

Trade-offs

  • A lock on each context access (per leaf and source, not per document).
  • Slices may still compute the scale twice (harmless, same value).
  • A caller-supplied map, for example in GroupingSearch(ValueSource, Map), is not covered.

What the checks validated

Checked earlier with a replaced ValueSource on main ea57360912ed: the independent reproducer's four tests pass with assertions on and off, 0 of 20 + 0 of 20 runs fail on a 64-vCPU host, and the same replacement passes on 4.0.0. Not run through gradle.

C: give each slice its own copy of the map (not run)

Copy the context per slice or leaf after createWeight. Not recommended alone: with the lazy scale() each copy would rescan the whole index. Only useful together with A.

Trade-offs

One small map copy per leaf; needs A to avoid repeated scans.

What the checks validated

Not run.

Downstream note

For downstream servers that turn client function syntax into these classes and search with an executor: a client can only break its own search (an error or a wrong ranking); other searches were unaffected.

How it was found

Semi-formal: a bytecode scan of Lucene's search-time classes found that scale() is the only built-in function source that writes to the per-search context outside createWeight. A bounded-exhaustive model of the JDK's IdentityHashMap growth rule then predicted which function sizes make that write grow the map, and directed runs failed at or next to those sizes.

The method in full182 words

Method: semi-formal (2026-10-26, main ea57360912ed).

  • A bytecode scan (an abstract interpretation of the class files with a per-basic-block symbolic operand stack that resolves the receiver of every field write and collection mutation) covered 689 subtypes of Weight, Query, ValueSource, Similarity and other search-time types in 17 jars. It flagged ScaleFloatFunction.createScaleInfo as the only built-in ValueSource method that puts into the per-search context map outside createWeight. Code reading showed that FunctionWeight hands that map to every slice.
  • A differential test of executor and sequential searches passed for small functions, so a bounded-exhaustive model of the real JDK growth rule enumerated every pair of entries before the search (up to 120) and puts during the search (up to 8): 968 cases, 108 of them grow the map during the search.
  • Directed experiments and sweeps on a 64-vCPU host then failed at the predicted growth points (20 and 41 padding sources: 5 of 5 runs each; none at 18, 19, 21, 22 and 42 in the independent reproducer's sweep; one throw in five runs at 40, next to the second).

Prior reports, history and checks

Prior reports

No report of this defect found (re-checked 2026-11-04). Related, not a report: in the review of #15574 (grouping test code) a reviewer and the author noted that a shared ValueSource context is not thread-safe when a source writes it in getValues(); it names none of the affected classes and reports no failure.

Searches5 searches

Re-checked 2026-11-03 in two independent passes and again on 2026-11-04: the searches found no Lucene issue, PR, JIRA entry, list thread or commit, and nothing outside Lucene, that describes this defect. The items below are related but about different defects or sites.

  • #15574 (FirstPassGroupingCollectorManager, merged 2026-06-02): review comments on TestGrouping.java (2026-04-20, 2026-05-19, 2026-05-20). A reviewer noted that if all per-slice collectors share one ValueSource context map "it needs to handle concurrency"; the author replied that some ValueSources "may write the context in getValues() so it's not thread-safe" and kept one map per collector in that test. It is about grouping test code, names none of FunctionQuery, FunctionRangeQuery, the sort field or ScaleFloatFunction, does not say that Lucene's own Weights share the map, and reports no failure; no issue or fix followed. It states the hazard in general, and a maintainer may read it as an earlier acknowledgement of that general hazard; it does not report this defect.
  • SOLR-5524 (2013, fixed): a single-threaded scale() context-key collision (a ClassCastException); same map, different defect.
  • #6399 (LUCENE-5335) and #7294 (LUCENE-6232): about the map's generic type.
  • SOLR-13350 (2019): a general remark to review Solr's Weights for thread safety; no function-query finding. OpenSearch#10079: a similar race in that project's own script code.
  • Side note (read, not executed): GroupingSearch(ValueSource, Map) hands one caller-supplied context to every per-slice ValueSourceGroupSelector since #15936 and #16247, the same hazard at another site.

Caveat: keyword search (first page per GitHub query); private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream projects, dev@, java-user@, issues@, commits@, JIRA2026-11-04scripted prior-report search, 51 queries (16 GitHub, 3 commit, 2 downstream, 20 list, 4 JIRA; 6 web queries planned), 367 distinct hitsthe closest hits are history and unrelated function-query items (#3905, #3957, #8653, #9951, #5107, the SOLR-5524 commit, #3652)
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream projects, dev@, java-user@, issues@, commits@, JIRA2026-11-04 (later)scripted prior-report search, a wider spec of 51 queries (16 GitHub, 3 commit, 2 downstream, 20 list, 4 JIRA; 6 web queries planned), 652 distinct hitsthe closest hits are history and unrelated items (#3905, LUCENE-2883, #2704, #7294, the SOLR-5524 commit mail)
GitHub (manual, read-only)2026-11-04ScaleFloatFunction; FunctionQuery and ValueSource items created since 2026-10-20; "ValueSource context concurrency"; newContext ValueSource; "non-threadsafe context"; commits on the five affected files since 2026-10-01nothing new: #5428 (unrelated), #15574 (related), no new items, no commits; upstream main f72004515ec2
the web2026-11-04two general web searches (ScaleFloatFunction concurrent NPE; FunctionQuery executor ValueSource context)nothing on this defect
GitHub, JIRA (LUCENE and SOLR), Solr, OpenSearch, Elasticsearch and Lucene.NET, the lists, the web, git history, the changed files of all 406 open PRs2026-10-26223 logged queries, six scripted prior-report search runsthe related #15574 review remark only

History

In Lucene since the function queries moved into the queries module (a993ae90323, LUCENE-3249, 2011); first released in 4.0.0-ALPHA; the code pattern has not changed since. Executed on 14 releases from 4.0.0-ALPHA to 10.6.0 and on main: affected each time. 3.6.x has no such API.

Versions tested7 rows

Introduced into Lucene by a993ae90323 (2011-06-28, LUCENE-3249 = #4322 "Moved FunctionQuery impls to new Queries module", Christopher John Male), with b3f0ae9b7a0 the day before (LUCENE-3240 = #4313: FunctionQuery, ValueSource and newContext). The search-time put came with that move from Solr code, where 8b241d92303 (2011-01-08, LUCENE-2831 = #3905, Yonik Seeley) made scale() cache its min/max in the shared context from getValues; the executor search with one shared Weight is from 87274d00ac1 (2011-01-05, LUCENE-2837 = #3911). svn era, no PR. FunctionRangeQuery shares its map the same way since it was added (13f8ab93a97, LUCENE-6720, 5.4.0). SOLR-5524 (4.7.0) changed only the context key. The bug's code pattern (one context per Weight shared by the slices, "searcher" in the map, the lazy put from getValues, nothing stored in createWeight) is present at all 123 release tags from 4.0.0-ALPHA to 10.5.2 in the history clone, although the code itself changed over time (for example the context key in 4.7.0); the five affected files are identical in 10.6.0 and main 5adff78bb168; upstream main f72004515ec2 (2026-11-03) has no later commit on them.

Versions and branches executed, with results
Version or commitVerdictResult
3.6.0, 3.6.2 beforeno context-map function API (API probe)
4.0.0-ALPHA first: 4.0.0-ALPHAhistory probe fails, assertions on and off
4.0.0 affectedhistory probe fails; a synchronized-map replacement passes
4.10.4 - 8.11.4 affectedhistory probe fails on 4.10.4, 5.4.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0 and 8.11.4; the finder's test fails on 8.11.4
9.0.0 - 10.5.2 affectedhistory probe fails on 9.0.0, 9.12.3, 10.0.0 and 10.5.2; this doc's test fails on 9.12.3
10.6.0 affectedthis doc's test fails, assertions on and off
main 5adff78bb168 affectedthis doc's test fails through gradle, assertions on and off; passes with fix A

Independent checks

An independent reproducer, an intended-behaviour verifier, the adjudication and reviews by another model family (OpenAI GPT-5.6, reading-only) all rated it real; the adjudication confirmed it with high confidence. Re-runs on main e1faeba08bde and this doc's runs on 5adff78bb168 still fail.

Checks and verdicts298 words

Neither review by another model family executed Java (both were reading-only); separate runs re-ran the finder's and the controls' tests, and those failed on stock and passed with the fix. A fourth independent re-check (2026-10-26) re-ran the finder's test and lowered the likelihood to contrived, because the silent-score form also needs a non-default Similarity.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundThree-method JUnit (FunctionQuery, FunctionRangeQuery, a BM25Similarity(1.8, 0.3) case) fails 3 of 3 on main (assertions on and off), 10.5.2, 9.12.3 and 8.11.4; 20 of 20 runs on a 64-vCPU host; 0 of 20 with the fix.
cross-model review before the claim (OpenAI GPT-5.6, reading-only) real (high)Root cause and fix correct, not intended; its open points (sort-field path, 4.0 lower bound) were later executed.
independent reproducer realOwn four-test JUnit, including the ValueSource sort-field path, fails 4 of 4 on main (on and off), 10.5.2 and 9.12.3; the padding sweep fails at the growth points (and once next to one); the synchronized-map control and the fix pass.
cross-model review, root-cause lens (OpenAI GPT-5.6, reading-only) real (high)Intended argument weak; root cause, fix and inputs agreed; first wrong step ScaleFloatFunction.java:98.
intended-behaviour verifier realRejected the "non-threadsafe javadoc", "legacy API", "@lucene.experimental" and "function too large" arguments; re-ran the finder's test (fails).
adjudication confirmed (high)Re-ran the reproducer's and the finder's tests on main (fail) and the synchronized-map control (passes 4 of 4); no duplicate.
re-run on main e1faeba08bde still reproducesThe finder's test fails 3 of 3 with assertions on and off (2026-11-02).
this doc's runs on 5adff78bb168 reproducesThe one-test repro fails 20 of 20 runs and through gradle; passes with fix A (gradle and 10 of 10 standalone runs).

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,398 words

Finding record

LFV-567, snapshot of 2026-11-06

Registry title
FunctionQuery, FunctionRangeQuery and ValueSource sort fields share one non-thread-safe context map across the slices of an executor search, and scale() (ScaleFloatFunction) writes to it during the search, so when that write grows the map the search throws NullPointerException or silently returns different scores
Area
queries / function queries (ValueSource context map under concurrent search)
Priority
Low, rank 700: With an executor search a FunctionQuery whose scale() grows the shared context map throws a NullPointerException or, with a non-default Similarity, silently gives different scores; the trigger is contrived, so it goes with the concurrent-search crash row LFV-125.
Impact class
silent wrong results
Impact note
Applications that search through an IndexSearcher with an executor (opt-in; in Solr, for example, a node executor plus multiThreaded=true [read]) with a FunctionQuery, FunctionRangeQuery or ValueSource sort whose function contains scale() and enough context-storing sources (query(), totaltermfreq(), sumtotaltermfreq(); about 20 with one scale()) that a search-time scale() put is the 22nd or 43rd entry of the shared IdentityHashMap (or close to it, through concurrent duplicate puts) are hit: the search fails with NullPointerException ('"searcher" is null') or, on a searcher with a non-default Similarity, silently returns different scores and top hits (20 of 20 runs of the one-test repro, 2026-11-04); in the controls a 10-source function passed 5 of 5 runs and a 19-source one failed once in 5, searchers without an executor and the asDoubleValuesSource adapter [read] are not affected, and no scale is needed.
Summary
You search with a FunctionQuery (or FunctionRangeQuery, or a sort by ValueSource.getSortField) whose function uses scale() together with about twenty context-storing sources such as query(), on an IndexSearcher built with an executor, and expect the same hits and scores as on a searcher without one, because IndexSearcher is documented as completely thread safe. Instead all slices share the Weight's one IdentityHashMap context (documented 'non-threadsafe'), ScaleFloatFunction.getValues puts its min/max entry into it from the slice threads, and when that put resizes the map other slices miss entries such as "searcher". The search then throws NullPointerException, or, on a searcher with a non-default Similarity, QueryValueSource silently rebuilds its weight with the default Similarity and the scores and top hits change.
Introduced
a993ae9032337ca8d5b2c7a4d58bf41a6f5053f5 (2011-06-28, svn r1140906, LUCENE-3249 = #4322 'Moved FunctionQuery impls to new Queries Module': brings ScaleFloatFunction with its lazy getValues-time put, QueryValueSource and MaxDocValueSource into Lucene's queries module) with its companion b3f0ae9b7a0d9e58b1e38d9b48fe6cb3cfbd4e5a (2011-06-27, r1140379, LUCENE-3240 = #4313: FunctionQuery with one context per FunctionWeight, and ValueSource.newContext, an IdentityHashMap documented 'non-threadsafe') [read: history clone; exec: 4.0.0-ALPHA fails, 3.6.x has no such API]. Code origin, all first released in 4.0.0-ALPHA: the search-time put comes from Solr's ScaleFloatFunction in 8b241d9230380b75157ad396c74ca1498f287666 (2011-01-08, LUCENE-2831 = #3905), the shared-Weight executor search from 87274d00ac17cbee79da2b162f8f169abdd61cd7 (2011-01-05, LUCENE-2837 = #3911), the context map from 1eb4697977563fa26562e5973cc9c06796ba3aa3 (2009-09-17, SOLR-1432, Solr code); the context key changed later (SOLR-5524, 4.7.0) without changing the lazy put. Pre-GitHub (svn), no PR. (2011-06-28 (move into Lucene; companion 2011-06-27; the search-time put itself dates from 2011-01-08 in Solr code))
First affected version
4.0.0-ALPHA [exec: fails -ea and -da; first GA release 4.0.0, also executed: fails]; FunctionRangeQuery from 5.4.0, where the class first appears [exec]; not affected: 3.6.2 and earlier, where the API does not exist [exec: API probe on 3.6.0 and 3.6.2]
Affected versions
Every release from 4.0.0-ALPHA through 10.6.0, plus main (ea57360912ed, 5adff78bb168); [exec, 2026-11-04] the one-test repro fails on the released 10.6.0 (-ea and -da) and 9.12.3 jars and on main 5adff78bb168 (-ea and -da). [exec] fail: 4.0.0-ALPHA, 4.0.0, 4.10.4, 5.4.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (history probe, public API, 300 searches per case; 26 of 26 stock JVM runs failed, every control without scale() passed; -da also on 4.0.0-ALPHA, 4.0.0, 8.11.4, 9.12.3, 10.5.2 and main); the finder's JUnit fails on main (-ea, -da), 10.5.2, 9.12.3 and 8.11.4; the independent reproduction's JUnit on main (-ea, -da), 10.5.2 and 9.12.3; separate runs on main (-ea, -da), 10.5.2 and 9.12.3. [read] the other release tags have the same code (code-identity check over 123 of 123 tags 4.0.0-alpha..10.5.2 in the history clone; the 8.9.0-8.11.x tags are not in the clone, 8.11.4 was executed). Not fixed upstream: no commit and no open PR changes this code (prior-report search, 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 agreed, 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 exhaustive check, code reading
Upstream and prior reports
no report of this defect found; one related item (searched 2026-10-26, read-only, by the finder, the intended-behaviour check and a follow-up search: 223 logged queries, six scripted prior-report searches over apache/lucene issues and PRs, commits, downstream repositories (apache/solr, OpenSearch, Elasticsearch, lucenenet), dev@, java-user@, issues@, commits@, builds@ and the Solr lists, the LUCENE and SOLR JIRA archives (16 extra JQL queries) and the web; the changed-file lists of all 406 open apache/lucene PRs; git history of the affected classes; 27 hits assessed). Related: in the review of apache/lucene#15574 (FirstPassGroupingCollectorManager, merged 2026-06-02; comments of 2026-04-20, 2026-05-19 and 2026-05-20 on TestGrouping.java) a reviewer noted that if all per-slice collectors share one ValueSource context map 'it needs to handle concurrency', and the author replied that other ValueSources 'may write the context in getValues() so it's not thread-safe' and kept one map per collector in the test; it names none of the affected classes, does not say that Lucene's own Weights share the map, reports no failure, and no issue or fix followed. Context only: SOLR-5524 (2013, a single-threaded ScaleFloatFunction context-key collision, fixed), #6399 (LUCENE-5335) and #7294 (LUCENE-6232) about the map's type, SOLR-13350 (a 2019 remark to audit Solr's Weights for thread safety), OpenSearch#10079 (an analogous bug in OpenSearch's own expression script). Not fixed: no commit changes this code and no open PR touches it (open #14775 edits ValueSource.java without changing the context).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), 2026-11-04: the one-test repro through gradle in a scratch worktree, stock (fails, assertions on and off) and with fix A (passes; the 112 tests under org.apache.lucene.queries.function, the whole lucene/queries suite with 513 tests, and :lucene:queries:check -x test); the standalone JUnit runner on main (20 of 20 runs fail; three controls), on the 10.6.0 and 9.12.3 release jars (fails), and with fix A applied (10 of 10 pass). Earlier records: the finder's and the verifiers' tests on main ea57360912ed (the function-query sources and IndexSearcher.java are identical), a history probe on 13 releases from 4.0.0-ALPHA to 10.5.2, and a re-run on main e1faeba08bde (2026-11-02).

Lucene and upstream sources

Finding record
  • a Lucene main checkout @ ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/function/ValueSource.java:66-71 (createWeight javadoc: store in createWeight, retrieve in getValues), :73-77 (newContext: 'Returns a new non-threadsafe context map', IdentityHashMap with "searcher"), :198-203 (asDoubleValuesSource: new context per getValues call), :373-383 (ValueSourceSortField.rewrite: one context for the rewritten sort), :434 (comparator doSetNextReader calls getValues(fcontext, ...)); FunctionQuery.java:70-71 (one context per FunctionWeight), :116 (AllScorer: func.getValues(weight.context, leaf)); FunctionRangeQuery.java:148-149, 154, 170 (FunctionRangeWeight vsContext), :177 (isCacheable); valuesource/ScaleFloatFunction.java:60-99 (createScaleInfo, put at :98), :106-108 (getValues: lazy createScaleInfo), :149-150 (createWeight only delegates); valuesource/QueryValueSource.java:81-86 (createWeight puts its weight), :113-128 (QueryDocValues fallback: new IndexSearcher with the default Similarity when "searcher" is missing); valuesource/MaxDocValueSource.java:49-50, DocFreqValueSource.java:60, IDFValueSource.java:51, NormValueSource.java:66, TFValueSource.java:54 (dereference context.get("searcher")); valuesource/SumTotalTermFreqValueSource.java:66, TotalTermFreqValueSource.java:73 (other createWeight puts); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:68-75 ('completely thread safe'), :194 (executor constructor: 'Runs searches for each segment separately')
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/function/ValueSource.java:66-68, (at 5adff78bb168) :73-78, :200, :379-382, :434
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:70-71, (at 5adff78bb168) :116; FunctionRangeQuery.java:148-149, :154, :170
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/ScaleFloatFunction.java:61-100, (at 5adff78bb168) :98, :106-109, :148-151; valuesource/QueryValueSource.java:85, :114-127, :150; valuesource/MaxDocValueSource.java:49-50
  • OpenJDK 25.0.3 src.zip java.base/java/util/IdentityHashMap.java:160, :337-349, :435-465, :473-504
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:71-75, (at 5adff78bb168) :194, :199
  • lucene/queries/src/java/org/apache/lucene/queries/function/ValueSource.java:66-68, (at 5adff78bb168) :73; FunctionQuery.java:38; valuesource/ScaleFloatFunction.java:98, :106-109, :148-151
  • lucene/queries/src/test/org/apache/lucene/queries/function/TestValueSources.java:201, (at 5adff78bb168) :593; lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:2563-2583, :2641-2675
History
  • a Lucene clone: git diff releases/lucene/10.6.0 5adff78bb168 (affected files identical)
Diagrams
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:70-71, (at 5adff78bb168) :116; valuesource/ScaleFloatFunction.java:98, :106-109; valuesource/QueryValueSource.java:114-127
  • OpenJDK 25.0.3 java/util/IdentityHashMap.java:456, :489-502

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

Repro files

About the diagrams

A picture of the shared context map and the growth during the search, built from the code at 5adff78bb168 and this doc's one test and its executed runs; the PNG is the summary still.

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.