All findings · Lucene finding · LFV-1187 · segmentorder-shared-comparator-hashmap-race-npe

SegmentOrder: two concurrent reorder() calls on one shared instance can throw NullPointerException

Since 10.4.0, a numeric SegmentOrder from fromSort() sorts every reorder() call with one shared comparator whose cache is a plain HashMap. Two threads calling reorder() on the same instance at once can make a lookup return null, and reorder() throws NullPointerException (20 of 20 rounds in the repro on main).

Summary

Priority
Low, rank 638Since 10.4.0, one numeric-sort SegmentOrder shared by concurrent searches can throw NullPointerException from reorder() on the first searches over new segments, because its comparator memo is an unsynchronized HashMap. It needs the opt-in helper, which has no known caller, used in that shared way, and the failure is loud and per search.
Impact
minor
Since
10.4.0 (#15591, 2026), when SegmentOrder was addedintroduced 2026-01-23
Affected
10.4.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of SegmentOrder's contract, witnessed by an executable repro

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 numeric SegmentOrder keeps one hidden cache, shared by every reorder() call, with no lock.

Background: what SegmentOrder does

  1. A reader holds one leaf per index segment, and a search visits the leaves in order. A sorted search that can stop early does less work when the segment with the best values comes first.
  2. SegmentOrder.fromSort(sort).reorder(reader) (new in 10.4.0) returns a view of the same segments in that order: for a descending numeric sort, by each segment's max value; for an ascending one, by its min. The segments' own data is untouched.
Diagram of the bug: one SegmentOrder built by fromSort with a descending LONG sort on f is shared by two threads. Each thread calls reorder(reader) on the same reader of 1,000 one-document segments, and both sorts call the same comparator, which caches each segment's max f in one HashMap with containsKey, put and get and no lock. When the threads write the map at the same time, one thread's get returns null and the unboxing at SegmentOrder.java:141 throws NullPointerException. One thread alone gets the view f = 999 first.
Two threads call reorder() on one shared SegmentOrder; both sort the same 1,000 segments with its one comparator and fill its one HashMap at the same time. A get() that misses an entry the other thread is still writing or moving returns null, and SegmentOrder.java:141 throws. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 1,000 one-document segments, each with a LongPoint f = 0 ... 999, and open one DirectoryReader. Build one SegmentOrder.fromSort(new Sort(new SortField("f", SortField.Type.LONG, true))) and keep it for every search, as you would keep a Sort.
  2. Two search threads call order.reorder(reader) at the same moment. You expect two views of the 1,000 segments in the order one thread gets: f = 999 first, f = 0 last.
  3. Instead at least one call throws NullPointerException: ... the return value of "java.util.Map.get(Object)" is null at SegmentOrder.java:141: 20 of 20 rounds with assertions on, 19 of 20 with them off.
  4. Why: every reorder() of that instance sorts with the same comparator, which caches each segment's max f in one plain HashMap (containsKey, put, get, no lock). For example, while one thread's put resizes the map, the other thread's get can miss an entry it has just added, and the null is unboxed to long.
  5. The map is written only for segments not cached yet, so it hits mostly the first concurrent searches on a newly opened or refreshed reader; after a lost race, a few later calls on that instance can fail too, even from one thread. A SegmentOrder per query, or one never used by two threads at once, never hits this race.

Who is affected

Applications on 10.4.0 to 10.6.0 or main that share one numeric SegmentOrder across concurrent search threads. Some searches fail with an exception, mostly the first ones on a new or refreshed reader; after a lost race a few later calls can fail too. No wrong results were seen. One instance per query avoids it; no outside caller is known.

Mechanism, terms and numbers623 words

SegmentOrder.fromSort(sort) (new in 10.4.0) returns a helper whose reorder(reader) gives a view of the reader with its segments ordered so that the best hits for the sort come first. For an INT, LONG, FLOAT or DOUBLE primary sort, numericSorter() builds one NumericFieldReaderContextComparator (SegmentOrder.java:91-92 at 5adff78bb168) and every reorder() hands that same instance to the view's constructor (:96-98), which sorts the leaves with it (BaseCompositeReader.java:76). The comparator caches each segment's sort value in a plain HashMap (:115) through containsKey, put and get with no lock, and unboxes the result (:138-141). When two threads call reorder() on the same instance and meet segments that are not cached yet, a get() can return null, and reorder() throws NullPointerException. The one-test repro fails through gradle on main in 20 of 20 iterations with assertions on and 19 of 20 with them off; building the comparator inside reorder() (+3/-2 lines) removes it. No wrong results, index damage or hang were seen. Opt-in helper, no known caller outside Lucene's own test; priority Low.

The bug

  1. fromSort with an INT, LONG, FLOAT or DOUBLE primary sort (or a SortedNumericSortField of one) returns numericSorter(...) (SegmentOrder.java:42-77). Other sort types return a no-op strategy (:79-85), which is not affected.
  2. numericSorter creates one NumericFieldReaderContextComparator outside reorder() (:91-92); every reorder() passes it to new BaseCompositeReader<>(leaves, comparator) (:95-98), whose constructor runs Arrays.sort with it on the calling thread (BaseCompositeReader.java:74-76).
  3. The comparator's getSortValue (:136-142) caches values in private final Map<Object, Long> cachedSortValues = new HashMap<>() (:115) keyed by the leaf's core cache key: if (containsKey(key) == false) put(key, loadSortValue(reader)); then return cachedSortValues.get(key); (:141), unboxed to long. No lock and no eviction.
  4. Two threads that fill one HashMap can lose or hide entries, or corrupt its structure (JDK 25 HashMap.java): an empty bucket is written with a plain store (tab[i] = newNode(...), :638), so one of two concurrent puts into it can be lost; and resize() publishes the new, empty table (table = newTab, :712) before it moves the old buckets (:714-717), so a get() meanwhile can miss a key just put. Either way :141 unboxes null.
  5. Every thread computes the same value for a given segment (loadSortValue, :144-172, reads that segment's own doc-values skipper or point min/max), so a lost or hidden entry shows up as the NullPointerException: that is the failure in all but one of the failed iterations recorded for this bug. The race can also corrupt the map's internal structure. In the fact check's re-run of the gradle command (assertions on), 1 of 20 failures was an AssertionError from HashMap's own tree-bin check (HashMap.java:2009, under the put at SegmentOrder.java:139). A probe that kept calling reorder() from one thread on an instance that had lost the race saw later calls fail too, in 5 of 670 failed rounds (three NullPointerExceptions in a row, then success; once a ClassCastException inside HashMap.TreeNode, HashMap.java:1995). Every successful call returned the correct order (897 race calls, 3,587 later calls) and none hung, so no wrong order, wrong hits or hang is claimed.

The numbers

1,000 one-document segments with f = 0 ... 999 and one descending LONG sort: one thread, or the same instance reused serially, gets f = 999, 998, ..., 0. Two threads at once: 20 of 20 rounds fail with assertions on and 19 of 20 with them off (gradle, main). With default settings, 1,000 puts grow the map 7 times, from 16 to 2,048 buckets, so each reorder() over 1,000 new segments has many windows.

Exposure and likelihood213 words
  • Who is hit. Applications that cache one SegmentOrder (for example one per Sort, like a cached Sort or Query) and call reorder() from several search threads.
  • When. The map is written only for segment cores the instance has not cached yet (SegmentOrder.java:138-139), so the risk is while concurrent calls insert new cores: mostly the first concurrent searches on a newly opened or refreshed reader, whenever a flush or merge adds segments. A call whose own segments are all cached can still fail if another thread is inserting at that moment, and after a lost race a few later calls on that instance can fail too, even from one thread (fact-check probe).
  • Effect. The failing reorder() call throws (almost always NullPointerException), so that search fails. Loud; no index damage, and no wrong results were seen.
  • Default vs opt-in. SegmentOrder is opt-in; nothing in Lucene calls it outside TestSegmentReordering. Once used, the default IndexWriterConfig, codec and directory suffice, with assertions on or off. A GitHub code search found no downstream caller (it indexes only part of GitHub).
  • Scale. The repro's 1,000 one-document segments only widen the window. It is a two-thread race, so real hit rates depend on how many new segments a call meets and on timing.

Minimal repro

One JUnit test, public API only: 1,000 one-document segments, one shared descending LONG SegmentOrder, and two threads that each call reorder() once at the same moment. It is a race (one round, no loop), so run it with -Ptests.iters=20. It fails on main with assertions on and off and passes with the fix.

TestSegmentOrderConcurrentReorder.java (lucene/core, package org.apache.lucene.search, 1 test)74 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.search;

import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.LongPoint;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.SegmentOrder;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.NamedThreadFactory;

public class TestSegmentOrderConcurrentReorder extends LuceneTestCase {

  // Two concurrent reorder() calls on one shared SegmentOrder should both succeed.
  public void testConcurrentReorderOnSharedSegmentOrder() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
        for (int i = 0; i < 1000; i++) {
          Document doc = new Document();
          doc.add(new LongPoint("f", i));
          w.addDocument(doc);
          w.flush();
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        SegmentOrder order =
            SegmentOrder.fromSort(new Sort(new SortField("f", SortField.Type.LONG, true)));
        CyclicBarrier barrier = new CyclicBarrier(2);
        Callable<IndexReader> reorder =
            () -> {
              barrier.await();
              return order.reorder(reader);
            };
        ExecutorService executor =
            Executors.newFixedThreadPool(2, new NamedThreadFactory("reorder"));
        try {
          Future<IndexReader> a = executor.submit(reorder);
          Future<IndexReader> b = executor.submit(reorder);
          a.get();
          b.get();
        } finally {
          TestUtil.shutdownExecutorService(executor);
        }
      }
    }
  }
}
Command
cp $REPRO/TestSegmentOrderConcurrentReorder.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSegmentOrderConcurrentReorder -Ptests.iters=20
# also with -Ptests.asserts=false
Expected
Both reorder() calls return a view of the 1,000 segments, f = 999 first.
Observed
NullPointerException ("the return value of java.util.Map.get(Object) is null") at SegmentOrder.java:141, in 20 of 20 iterations (19 of 20 with assertions off).

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

More files, environment, full output349 words

TestSegmentOrderConcurrentReorder (one method, extends LuceneTestCase, package org.apache.lucene.search of the lucene/core tests, next to TestSegmentReordering). A CyclicBarrier releases the two tasks together; Future.get() rethrows the failure.

Concurrency is the bug, so the test has two threads. It is probabilistic, and no deterministic public-API repro was found: IndexReader.CacheKey is a final class with identity hashing, and no user code runs inside the HashMap calls. About half of cold single runs fail (5 of 9 with assertions on, 5 of 8 off, standalone runner); 20 iterations make a failure near-certain. The 1,000 segments only widen the window: the same shape with 40 segments failed 120 of 200 warm iterations and 2 of 10 cold single runs (assertions off), so a smaller index also works with 20 iterations.

Suggested regression test: this same test. It passes checkGoogleJavaFormat, ecjLintTest and forbiddenApisTest. It differs from the hunt's minimal repro only in a shorter comment and a named thread factory (the forbidden-API rules reject Executors.newFixedThreadPool(int)), and was re-run as shown here.

Under gradle's default test JVM flags every failure carried the message; with a standalone runner, later failures in one JVM lose it (the JIT's fast-throw) and show a bare NullPointerException.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.4.0, 10.5.2, 10.6.0: an equivalent test fails; 9.12.3, 10.3.2: no such class
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64, 32 cores (the only platform run; no x86 run; the code path is pure Java; gradle's test JVM uses -XX:ActiveProcessorCount=1 and still fails)
Command (environment)
cp $REPRO/TestSegmentOrderConcurrentReorder.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSegmentOrderConcurrentReorder -Ptests.iters=20
# also with -Ptests.asserts=false

Observed output

TestSegmentOrderConcurrentReorder > testConcurrentReorderOnSharedSegmentOrder > testConcurrentReorderOnSharedSegmentOrder[seed=[1187B001:13A24C3AEC58A3F]] FAILED
    java.util.concurrent.ExecutionException: java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because the return value of "java.util.Map.get(Object)" is null
        at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:124)
        at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:193)
        at org.apache.lucene.search.TestSegmentOrderConcurrentReorder.testConcurrentReorderOnSharedSegmentOrder(TestSegmentOrderConcurrentReorder.java:66)
        Caused by:
        java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because the return value of "java.util.Map.get(Object)" is null
            at org.apache.lucene.index.SegmentOrder$NumericFieldReaderContextComparator.getSortValue(SegmentOrder.java:141)
            at org.apache.lucene.index.SegmentOrder$NumericFieldReaderContextComparator.compare(SegmentOrder.java:132)
            at org.apache.lucene.index.SegmentOrder$NumericFieldReaderContextComparator.compare(SegmentOrder.java:113)
            at java.base/java.util.TimSort.countRunAndMakeAscending(TimSort.java:356)
            at java.base/java.util.TimSort.sort(TimSort.java:234)
            at java.base/java.util.Arrays.sort(Arrays.java:1230)
            at org.apache.lucene.index.BaseCompositeReader.<init>(BaseCompositeReader.java:76)
            at org.apache.lucene.index.SegmentOrder$2$1.<init>(SegmentOrder.java:98)
            at org.apache.lucene.index.SegmentOrder$2.reorder(SegmentOrder.java:97)
            at org.apache.lucene.search.TestSegmentOrderConcurrentReorder.lambda$testConcurrentReorderOnSharedSegmentOrder$0(TestSegmentOrderConcurrentReorder.java:59)
...
20 tests completed, 20 failed

# with -Ptests.asserts=false (seed 1187B002): 20 tests completed, 19 failed (the same exception and stack)

Why it looks unintended

  • fromSort() is a static factory over an immutable Sort; nothing says the instance holds state or must not be shared.
  • Lucene marks non-shareable helpers (StoredFields; HnswGraphBuilder: "cannot be shared across threads"); SegmentOrder has no such note.
  • The cache lives on the instance, which only pays off across calls (a per-call cache would serve one sort as well).
The full argument298 words
  • The API looks stateless. fromSort: "Builds a SegmentSorter that will reorder segments based on a given numeric Sort" (SegmentOrder.java:36-41); reorder: "Produces a new view over an IndexReader by re-ordering the reader's segments" (:33-34). No word on threads, reuse or a cache anywhere in the file.
  • Lucene documents the opposite case explicitly. StoredFields.java:29-30 ("This class is not thread-safe and should only be consumed in the thread where it was acquired"), HnswGraphBuilder.java:45 ("This class is NOT thread safe, it cannot be shared across threads"); a verifier counted 24 such notes in core. SegmentOrder is built from a Sort, which is immutable (lucene/MIGRATE.md:1638, "Sort is immutable (LUCENE-9325)"), and it carries no such note.
  • The design suggests reuse. The cache key is reader.getCoreCacheHelper().getKey() (:137), stable across reader reopens, and the cache is owned by the instance. A per-call cache would still serve the many comparisons inside one sort; keeping it on the instance, rather than per call, pays off only across calls. #15436 (closed) built a comparator per search with a cache keyed by LeafReaderContext.ord; #15591's first commit moved the comparator up into numericSorter and switched to the core key. No review comment on either PR discusses threads, sharing or the cache's lifetime.
  • The fix is expected to be cheap, but it is not benchmarked. On main, since #16388 (1593f87193b), a segment's skipper and point min/max are available without IO (:144-172); fix A gives up only the reuse of sort values across calls. For context, in #15436 (a comparator per search) its author measured a fixed per-search overhead of the whole re-sorting path (TermDTSort -37.2% QPS) and wrote "Given the fixed overhead of segment sorting, I think having this as something that happens automatically isn't viable".
The devil's-advocate check153 words

Strongest case for "intended": no document promises thread safety, Java's usual rule is that an undocumented class is not thread-safe, the PR title says "per-query segment re-ordering", CHANGES says "re-order segments at query time" (lucene/CHANGES.txt:1426), and the only caller (TestSegmentReordering.java:382) builds one per use. The cross-model reviewer stopped at "uncertain" for this reason.

Why it does not settle it: "per query" says when the application chooses to reorder, not how long the object lives; the object exposes no state, so a user has no hint that sharing is unsafe; and keeping the cache on the instance, rather than per call, only pays off across calls. A caveat against the bug reading: the core key may simply have been forced by the comparator receiving LeafReaders (no ord available), so it is weak evidence of intended reuse; the case rests on the API shape. Hence medium confidence and Low priority.

Suggested fix

Recommended Build the comparator inside reorder() (A)

Build the comparator, and so its cache, inside reorder() instead of once per SegmentOrder (+3/-2 lines, one file): the instance then holds no mutable state. With it the repro passes 20 of 20 and 50 of 50, TestSegmentReordering passes, and format, ecj and forbidden-API checks pass.

  private static SegmentOrder numericSorter(
      String field, Long missingValue, boolean reverse, ToLongFunction<byte[]> pointDecoder) {
    return new SegmentOrder() {
      @Override
      public IndexReader reorder(IndexReader reader) throws IOException {
        // a fresh comparator (and sort-value cache) per call: the strategy holds no mutable state
        NumericFieldReaderContextComparator comparator =
            new NumericFieldReaderContextComparator(field, missingValue, reverse, pointDecoder);
        return new BaseCompositeReader<>(
All options and trade-offs4 options ยท 477 words

Option A was applied to 5adff78bb168 in a fresh scratch worktree and run through gradle. B, C and D were not run. None of A, B or D fixes LFV-296 (same method, a null core cache helper); C would fix both.

Build the comparator inside reorder() (A) (validated by the checks) (recommended)

Move the new NumericFieldReaderContextComparator(...) of numericSorter (SegmentOrder.java:91-92) into the returned reorder(), so each call sorts with its own comparator and cache.

  private static SegmentOrder numericSorter(
      String field, Long missingValue, boolean reverse, ToLongFunction<byte[]> pointDecoder) {
    return new SegmentOrder() {
      @Override
      public IndexReader reorder(IndexReader reader) throws IOException {
        // a fresh comparator (and sort-value cache) per call: the strategy holds no mutable state
        NumericFieldReaderContextComparator comparator =
            new NumericFieldReaderContextComparator(field, missingValue, reverse, pointDecoder);
        return new BaseCompositeReader<>(

Trade-offs

Each reorder() loads the sort value once per distinct segment core it sees (a skipper or point min/max read) instead of reusing values from earlier calls; expected to be cheap on main, but no benchmark was run. Each call's map is then used only by the calling thread (Arrays.sort is sequential), so the race cannot occur. It also ends the cache's growth across calls (one small entry per segment core ever seen; not claimed as a bug). It does not touch the getCoreCacheHelper().getKey() call (:137), so LFV-296 stays.

What the checks validated

  • Gradle, fresh scratch worktree of 5adff78bb168 + the test, 2026-11-03: stock, the test fails 20 of 20 (assertions on) and 19 of 20 (off).
  • + fix: the test passes 20 of 20 (on) and 50 of 50 (off); TestSegmentReordering (3 tests) passes 60 of 60 and 150 of 150 runs; checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest on lucene/core pass.
  • Earlier, the same change shadowed into a standalone runner: 0 failures in 50 (finder), 50 (independent reproducer), 30 (cross-model reviewer), 20 (a separate re-run), and 20 + 20 (minimal repro, assertions on and off) iterations.
  • Not run: the full core suite, nightly tests, a benchmark.

Keep one cache but make it concurrent (B) (not run)

ConcurrentHashMap plus computeIfAbsent(key, k -> loadSortValue(reader)).

Trade-offs

Keeps reuse across calls; loadSortValue does not touch the map, so no recursive update. Keeps the never-evicted growth. Not run.

What the checks validated

Not run.

Per-call cache keyed by the leaf itself (C) (not run)

Inside reorder(), cache by LeafReader identity (an IdentityHashMap) or compute every leaf's value into an array before sorting.

Trade-offs

Removes the race and never calls getCoreCacheHelper(), so it would also fix LFV-296 in one change; a larger change than A. Not run.

What the checks validated

Not run.

Document it instead (D) (not run)

State on SegmentOrder that an instance is not thread-safe and should be created per query.

Trade-offs

No code change, but it keeps a trap in an API that looks stateless.

What the checks validated

Not run.

Downstream note

Relevant only to products that cache one SegmentOrder per sort and share it across search threads: there, some searches fail right after a reader is opened or refreshed; queries or documents alone cannot create that sharing.

How it was found

A proof-obligation audit of SegmentOrder's contract: reorder() of an instance built from an immutable Sort should depend only on its reader argument and share no state across calls. The audit found one comparator cache shared by every call; a two-thread public-API JUnit test then witnessed the NullPointerException.

The method in full127 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro), on main 5adff78bb168, 2026-11-03.

  • The obligation: a strategy from fromSort() exposes no state, so each reorder(reader) should be a function of reader alone. numericSorter (SegmentOrder.java:89-111) breaks it: one comparator with a mutable, unsynchronized cache is captured by every call.
  • The finder wrote a public-API JUnit test (two readers of 400 one-document segments, one shared SegmentOrder, two threads) that fails in most iterations on main, 10.6.0 and 10.4.0, and the per-call-comparator change that makes it pass.
  • An independent reproducer, a cross-model reviewer, a devil's-advocate verifier and an adjudication re-ran it or wrote their own tests ("Prior reports, history and checks"). The adjudication chose the shared-reader shape as the one repro.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (Lucene and three downstream projects), all 409 open Lucene PRs, four mailing lists, the JIRA archive and the web (2026-11-03, re-checked the same day). Only the introducing PR #15591 and its closed predecessor #15436 discuss the feature, and neither mentions threads or the cache.

Searches3 searches

Context, not reports: #15591 (the introducing PR; reviews discuss only how the test detects re-ordering); #15436 (closed predecessor; a comparator per search); the dev@ thread "Sort-based skipping and concurrency" (2026-01-15; concurrency inside one search); mikemccand/luceneutil#595 (cites how fromSort resolves the sorted-numeric type); LFV-296 (another finding on this site; same method, different cause). Search limits: keyword search, partial GitHub code search, private channels not searchable.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr; all of GitHub)2026-11-03a scripted prior-report search run (51 queries over GitHub, the lists, the JIRA archive and the web), 45 GraphQL searches, REST and code searches, the history of SegmentOrder.java on main and branch_10_6, the changed files of all 409 open PRsno report; #15591, #15436 and the nearest items read in full
Mailing lists (dev@, java-user@, issues@, commits@)2026-11-0320 archive queriesno report; one dev@ thread on intra-query concurrency (unrelated)
Re-check (apache/lucene)2026-11-03SegmentOrder thread / NullPointerException / HashMap, NumericFieldReaderContextComparator, fromSort reorder; issues and PRs updated since 2026-11-01; open PRs touching SegmentOrder.javanothing new; upstream main 1c2072f0284 has the same file

History

Introduced in 10.4.0 by #15591 (d455fefc6b6, 2026-01-23), which created SegmentOrder with the shared comparator. Affects 10.4.0 to 10.6.0 and main; earlier versions lack the class. Not fixed upstream.

Versions tested7 rows

#15591 "Add SegmentOrder utility class for per-query segment re-ordering" (@romseygeek; CHANGES credits Alan Woodward) is d455fefc6b6 on main and 7b71560832c on branch_10x, both 2026-01-23; CHANGES 10.4.0, New Features. The shared comparator and its HashMap are there from the PR's first revision. The only later commit to the file, 1593f87193b (#16388, 2026-07-14, main only), changes loadSortValue and not the cache. git tag --contains 7b71560832c lists 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0. Not a regression. The counts below are hit rates of a race and vary with host load; they are not differences between versions.

Versions and branches executed, with results
Version or commitVerdictResult
9.12.3, 10.3.2 API absentSegmentOrder does not exist (compile error)
10.4.0 first release16 of 20 iterations fail (assertions off); the finder's test 18 of 20
10.5.0, 10.5.1 source readsame SegmentOrder.java (the tags contain 7b71560832c)
10.5.2 affected17 of 20 (assertions off), 15 of 20 (on)
10.6.0 (4ae946e5a9b) affectedfour runs with assertions off, from 5 of 10 to 20 of 20
main 5adff78bb168 affectedgradle: 20 of 20 (on), 19 of 20 (off); standalone: 17 of 20 each
upstream main 1c2072f0284, branch_10_6 source readsame code (2026-11-03); no open PR touches the file

Independent checks

A finder, an independent reproducer, a cross-model reviewer (OpenAI GPT-5.6, executed: uncertain, not "not real"), a devil's-advocate verifier and an adjudication all reproduced it; an audit and a final repro check kept it at Low. This doc's gradle runs reproduced it on main and validated the fix.

Checks and verdicts406 words

Every failure in the executed checks was the same NullPointerException at SegmentOrder.java:141, except one: in the fact check's gradle run with assertions on, 1 of 20 failures was an AssertionError from HashMap's own tree-bin check (HashMap.java:2009). Every check of the per-call comparator passed. The cross-model reviewer ran on main ea57360912ed, where SegmentOrder.java and BaseCompositeReader.java are identical to 5adff78bb168; its test was re-run on 5adff78bb168 and fails there too (4 of 15 and 2 of 15).

Each independent check and its verdict
CheckVerdictWhat it found
finder (contract audit, then a JUnit) reproducedTwo-reader test: main 41 of 50 (on) and 42 of 50 (off), 10.6.0 12 of 20, 10.4.0 18 of 20; the fix 0 of 50; TestSegmentReordering 3 of 3.
independent reproducer realOwn shared-reader test (the shape of this repro): main 30 of 50 and 45 of 50; 10.6.0, 10.5.2 and 10.4.0 fail; cold single runs about half; the fix 0 of 50.
cross-model review, refute lens (OpenAI GPT-5.6, executed) uncertain (medium)Own two-reader test fails 11 of 30 (off) and 5 of 20 (on); sequential control passes 20 of 20; the fix 30 of 30. Agrees on cause, fix and in-contract input; stops at uncertain only because no thread-safety promise is written down.
devil's-advocate verifier real (medium), LowReplays fail 8 of 10 and 4 of 10 on main, 5 of 10 on 10.6.0; the "per query" reading is rejected; the cache growth is too small to claim.
adjudication, separate re-runs, audit and final repro check confirmed (medium), LowAdjudication 18 of 20 with assertions on and off; separate re-runs 18 of 20 and 15 of 20, fix 20 of 20; audit 9 of 10, fix 10 of 10.
this doc's gradle runs reproduced; fix validatedStock 20 of 20 and 19 of 20 fail; with the fix 20 of 20 and 50 of 50 pass, TestSegmentReordering passes, format, ecj and forbidden-API checks pass.
fact check (gradle re-run, standalone runs, follow-up probe) real, Low; wording correctedThe doc's gradle command fails 20 of 20 (on; one of them the HashMap AssertionError) and 18 of 20 (off); standalone 19 of 20 and 17 of 20; with the fix 20 of 20 pass, on and off. The probe: in 5 of 670 failed rounds, later single-thread calls on the same instance failed too; every successful call returned the correct order.

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

Finding record

LFV-1187, snapshot of 2026-11-06

Registry title
A SegmentOrder built by SegmentOrder.fromSort(numeric Sort) sorts the leaves of every reorder() call with one shared comparator whose per-segment memo is an unsynchronized HashMap, so two threads calling reorder() on the same SegmentOrder at the same time can throw NullPointerException (Map.get returns null and is unboxed) (since 10.4.0)
Area
core / index: SegmentOrder (query-time segment re-ordering helper)
Priority
Low, rank 638: Since 10.4.0, one numeric-sort SegmentOrder shared by concurrent searches can throw NullPointerException from reorder() on the first searches over new segments, because its comparator memo is an unsynchronized HashMap. It needs the opt-in helper, which has no known caller, used in that shared way, and the failure is loud and per search.
Impact class
minor
Impact note
Only applications that keep one SegmentOrder from SegmentOrder.fromSort with an INT, LONG, FLOAT or DOUBLE primary sort (the opt-in helper added in 10.4.0; nothing in Lucene outside its own test calls it) and call reorder() on it from two or more threads at the same time, on segment cores that strategy has not cached yet (for example the first concurrent searches on a freshly opened or reopened reader), get a NullPointerException out of some of those reorder() calls, so those searches fail; the failure is loud, no wrong results or index damage were observed or are claimed, a SegmentOrder built per query never fails, and released 10.4.0 to 10.6.0 are affected with the default IndexWriterConfig, codec and directory, -ea or -da.
Summary
You build one SegmentOrder with SegmentOrder.fromSort(new Sort(new SortField("f", SortField.Type.LONG, true))) and share it between searches, as you would a Sort or a Query, so two threads call order.reorder(reader) on the same freshly opened reader at the same time. You expect each call to return a re-ordered view: the strategy is built from an immutable Sort, its API exposes no state, and its javadoc says nothing about threads. Instead every reorder() of that strategy sorts the leaves with one comparator whose per-segment memo is a plain HashMap filled with containsKey/put/get and no lock, so a concurrent put or resize can make Map.get return null and reorder() throws NullPointerException 'Cannot invoke "java.lang.Long.longValue()" because the return value of "java.util.Map.get(Object)" is null' at SegmentOrder.java:141.
Introduced
d455fefc6b63f90216c938fda061286918fee37e on main and its branch_10x copy 7b71560832c6f81cc6b7f91f536b6cf618dbbd65: apache/lucene#15591 'Add SegmentOrder utility class for per-query segment re-ordering' (author @romseygeek; CHANGES.txt credits Alan Woodward). It created SegmentOrder, whose numericSorter() builds one NumericFieldReaderContextComparator, with a HashMap memo keyed by the leaf's core cache key, outside reorder(), so every reorder() of the returned strategy sorts with that one instance [read: git blame on main attributes numericSorter :89-111, the comparator class :113-142, the HashMap field :115 and the containsKey/put/get at :136-142 to d455fefc6b6; the only later commit on the file, 1593f87193b (#16388, main only, 2026-07-14), changes loadSortValue :145 and not the memo] [exec: the class is absent on 10.3.2 and 9.12.3 (compile error) and the test fails on 10.4.0]. Design history: the closed, unmerged predecessor #15436 built a fresh comparator per search with a memo keyed by LeafReaderContext.ord; #15591's first commit 91bbb0f9d68 moved the comparator up into numericSorter and switched to the core cache key; no review comment on either PR discusses threads, sharing or the memo's lifetime. Not a regression: the shared memo has been there since the class was created. (2026-01-23 (author and commit date of d455fefc6b6, 2026-01-23T10:34:54Z; branch_10x copy 7b71560832c committed 2026-01-23T10:43:40Z; PR #15591 merged 2026-01-23T10:34:54Z))
First affected version
10.4.0 [exec: the one-test repro fails on 10.4.0 (16 of 20 iterations, -da, history step; the independent reproduction 16 of 20; the finder's two-reader test 18 of 20); 10.3.2 and 9.12.3 give a compile error because the class is absent] [read: 7b71560832c is in releases/lucene/10.4.0 and in no earlier tag]
Affected versions
10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT) [read: git tag --contains 7b71560832c lists releases/lucene/10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0; SegmentOrder.java has the same shared comparator (:91), HashMap memo (:115) and unboxing get (:141) in all five tags] [exec, per iteration of one two-thread round: main -ea and -da (finder 41/50 and 42/50, the independent reproduction 30/50 and 45/50, adjudication 18/20 and 18/20, history 20/20 -ea, a separate re-run 18/20 -ea and 15/20 -da), 10.6.0 (-da: the independent reproduction 18/20, history 20/20, the intended-behaviour check 5/10, finder 12/20), 10.5.2 (the independent reproduction 17/20 -da, 15/20 -ea), 10.4.0 (-da: the independent reproduction 16/20, history 16/20, finder 18/20)]. Not affected: 10.3.2 and earlier and all of 9.x (API absent; compile error on 10.3.2 and 9.12.3 [exec]). Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) has the same SegmentOrder.java, branch_10_6 has the same code, the newest commit on the file is still 1593f87193b (author date 2026-07-14), and no open PR touches SegmentOrder.java or TestSegmentReordering.java [read, re-checked 2026-11-03]. Not new in 10.6. The counts are hit rates of a race that cannot be made deterministic (they vary with host load), not differences between versions. All runs used JDK 25.0.3 on one aarch64 Linux host.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that stayed undecided, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03: a scripted prior-report search in two runs (16 apache/lucene issue/PR queries, 3 commit queries, 2 downstream queries over Elasticsearch, OpenSearch and Solr, 20 queries over the dev@, java-user@, issues@ and commits@ lists, 4 JIRA-archive queries, 6 web queries); 28 GitHub GraphQL searches on apache/lucene and 17 on downstream repos and all of GitHub; REST issue/PR searches; GitHub code search for SegmentOrder.fromSort; PRs and issues created or updated since 2026-10-30; the changed files of all 409 open apache/lucene PRs (none touches SegmentOrder.java or TestSegmentReordering.java; #16847 changes only BaseCompositeReader's doc-id types); the upstream commit history of SegmentOrder.java on main and branch_10_6; #15591 and #15436 read in full, with commits, reviews and review comments; the independent reproduction's, the intended-behaviour check's and the adjudication's searches agree). Re-checked the same day: 'SegmentOrder' in apache/lucene issues and PRs returns only #15591; 'SegmentOrder thread', 'SegmentOrder NullPointerException', 'SegmentOrder HashMap', 'NumericFieldReaderContextComparator', 'fromSort reorder', 'segment re-ordering concurrent' and 'reorder segments thread safe' return nothing; none of the 45 issues and PRs updated since 2026-11-01 names SegmentOrder or re-ordering; of the 29 open PRs updated since 2026-10-31 only #16847 touches BaseCompositeReader.java (unrelated) and none touches SegmentOrder.java; upstream main 1c2072f0284 still has the same file. Nearest items, none of them this bug: #15591 (the introducing PR; nothing on threads, sharing or the memo), #15436 (closed predecessor, a comparator per search), the dev@ thread 'Sort-based skipping and concurrency' (2026-01-15, about intra-query search concurrency), mikemccand/luceneutil#595 (cites how fromSort resolves the numeric type), and the hunt's own LFV-296 (same method, null core cache helper).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT) through gradle with assertions on and off (this doc's runs, 2026-11-03, OpenJDK 25.0.3, Linux aarch64) and with a standalone JUnit runner; the 10.4.0, 10.5.2 and 10.6.0 release jars (standalone runner); 9.12.3 and 10.3.2 (the class is absent: compile error).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:30-41 (at 5adff78bb168) (class and method javadoc: 'Utility class to re-order segments within an IndexReader to assist in early termination', 'Produces a new view over an IndexReader by re-ordering the reader's segments', fromSort 'Builds a SegmentSorter'; no thread-safety or lifetime statement)
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:89-111 (at 5adff78bb168) (numericSorter: one NumericFieldReaderContextComparator built at :91-92 outside reorder() and captured by every reorder() at :96-98)
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:113-142 (at 5adff78bb168) (comparator: 'private final Map<Object, Long> cachedSortValues = new HashMap<>()' at :115; getSortValue containsKey :138, put :139, 'return cachedSortValues.get(key)' unboxed at :141; no lock, no eviction)
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-76 (at 5adff78bb168) (the constructor sorts the sub-readers with the given comparator, Arrays.sort at :76)
  • lucene/core/src/test/org/apache/lucene/search/TestSegmentReordering.java:382 (at 5adff78bb168) ('SegmentOrder.fromSort(sort).reorder(reader)', the only in-tree use)
  • lucene/CHANGES.txt:1426 (at 5adff78bb168) ('GITHUB#15591: Add SegmentOrder helper class to re-order segments at query time to align with sorts. (Alan Woodward)')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:30-41 (at 5adff78bb168) (javadoc), :42-87 (fromSort), :89-111 (numericSorter; :91-92 one comparator; :95-98 reorder), :113-142 (comparator; :115 HashMap; :136-142 getSortValue; :141 get unboxed), :144-172 (loadSortValue)
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-76 (at 5adff78bb168) (constructor sorts the sub-readers with the comparator)
  • OpenJDK 25.0.3 src.zip java.base/java/util/HashMap.java:638 (putVal stores into an empty bucket), :669 (resize on ++size > threshold), :684-717 (resize: table = newTab at :712 before the old buckets are moved)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:138-139 (at 5adff78bb168)
  • Lucene main 5adff78bb168 grep of lucene/*/src for SegmentOrder (SegmentOrder.java and TestSegmentReordering.java only)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:33-41, (at 5adff78bb168) :137, :144-172
  • lucene/core/src/java/org/apache/lucene/index/StoredFields.java:29-30; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:45
  • lucene/CHANGES.txt:1426; (at 5adff78bb168) lucene/core/src/test/org/apache/lucene/search/TestSegmentReordering.java:382
  • https://github.com/apache/lucene/pull/15591, https://github.com/apache/lucene/pull/15436
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:91-98, (at 5adff78bb168) :137, :144-172
Prior reports
  • https://github.com/apache/lucene/pull/15591, https://github.com/apache/lucene/pull/15436, https://lists.apache.org/thread/4ztyn0hxs01vrqjmnjdthzgq1z5mn1yg, https://github.com/mikemccand/luceneutil/pull/595
History
  • a Lucene clone: git tag --contains 7b71560832c (releases/lucene/10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0)
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:89-142 (at 5adff78bb168)
  • OpenJDK 25.0.3 src.zip java.base/java/util/HashMap.java:638, :684-717

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

Repro files

About the diagrams

Drawn from the code at 5adff78bb168, JDK 25's HashMap source and the executed repro 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.