A numeric SegmentOrder keeps one hidden cache, shared by every reorder() call, with no lock.
- 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.
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.
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.- Index 1,000 one-document segments, each with a
LongPointf = 0 ... 999, and open oneDirectoryReader. Build oneSegmentOrder.fromSort(new Sort(new SortField("f", SortField.Type.LONG, true)))and keep it for every search, as you would keep aSort. - 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. - Instead at least one call throws
NullPointerException: ... the return value of "java.util.Map.get(Object)" is nullatSegmentOrder.java:141: 20 of 20 rounds with assertions on, 19 of 20 with them off. - Why: every
reorder()of that instance sorts with the same comparator, which caches each segment's max f in one plainHashMap(containsKey,put,get, no lock). For example, while one thread'sputresizes the map, the other thread'sgetcan miss an entry it has just added, and thenullis unboxed tolong. - 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
SegmentOrderper query, or one never used by two threads at once, never hits this race.
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.
fromSortwith an INT, LONG, FLOAT or DOUBLE primary sort (or aSortedNumericSortFieldof one) returnsnumericSorter(...)(SegmentOrder.java:42-77). Other sort types return a no-op strategy (:79-85), which is not affected.numericSortercreates oneNumericFieldReaderContextComparatoroutsidereorder()(:91-92); everyreorder()passes it tonew BaseCompositeReader<>(leaves, comparator)(:95-98), whose constructor runsArrays.sortwith it on the calling thread (BaseCompositeReader.java:74-76).- The comparator's
getSortValue(:136-142) caches values inprivate final Map<Object, Long> cachedSortValues = new HashMap<>()(:115) keyed by the leaf's core cache key:if (containsKey(key) == false) put(key, loadSortValue(reader));thenreturn cachedSortValues.get(key);(:141), unboxed tolong. No lock and no eviction. - Two threads that fill one
HashMapcan lose or hide entries, or corrupt its structure (JDK 25HashMap.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; andresize()publishes the new, empty table (table = newTab,:712) before it moves the old buckets (:714-717), so aget()meanwhile can miss a key just put. Either way:141unboxesnull. - 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 theNullPointerException: 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 anAssertionErrorfromHashMap's own tree-bin check (HashMap.java:2009, under theputatSegmentOrder.java:139). A probe that kept callingreorder()from one thread on an instance that had lost the race saw later calls fail too, in 5 of 670 failed rounds (threeNullPointerExceptions in a row, then success; once aClassCastExceptioninsideHashMap.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.
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 perSort, like a cachedSortorQuery) and callreorder()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 alwaysNullPointerException), so that search fails. Loud; no index damage, and no wrong results were seen. - Default vs opt-in.
SegmentOrderis opt-in; nothing in Lucene calls it outsideTestSegmentReordering. Once used, the defaultIndexWriterConfig, 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.
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.
/*
* 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);
}
}
}
}
}
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") atSegmentOrder.java:141, in 20 of 20 iterations (19 of 20 with assertions off).
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux 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) |
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
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)fromSort()is a static factory over an immutableSort; nothing says the instance holds state or must not be shared.- Lucene marks non-shareable helpers (
StoredFields;HnswGraphBuilder: "cannot be shared across threads");SegmentOrderhas 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.SegmentOrderis built from aSort, 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 byLeafReaderContext.ord; #15591's first commit moved the comparator up intonumericSorterand 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.
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.
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,forbiddenApisMainandforbiddenApisTestonlucene/corepass. - 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.
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.
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.
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.
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.
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 eachreorder(reader)should be a function ofreaderalone.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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr; all of GitHub) | 2026-11-03 | a 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 PRs | no report; #15591, #15436 and the nearest items read in full |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-11-03 | 20 archive queries | no report; one dev@ thread on intra-query concurrency (unrelated) |
| Re-check (apache/lucene) | 2026-11-03 | SegmentOrder thread / NullPointerException / HashMap, NumericFieldReaderContextComparator, fromSort reorder; issues and PRs updated since 2026-11-01; open PRs touching SegmentOrder.java | nothing new; upstream main 1c2072f0284 has the same file |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.3, 10.3.2 | API absent | SegmentOrder does not exist (compile error) |
| 10.4.0 | first release | 16 of 20 iterations fail (assertions off); the finder's test 18 of 20 |
| 10.5.0, 10.5.1 | source read | same SegmentOrder.java (the tags contain 7b71560832c) |
| 10.5.2 | affected | 17 of 20 (assertions off), 15 of 20 (on) |
10.6.0 (4ae946e5a9b) | affected | four runs with assertions off, from 5 of 10 to 20 of 20 |
main 5adff78bb168 | affected | gradle: 20 of 20 (on), 19 of 20 (off); standalone: 17 of 20 each |
upstream main 1c2072f0284, branch_10_6 | source read | same code (2026-11-03); no open PR touches the file |
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).
| Check | Verdict | What it found |
|---|---|---|
| finder (contract audit, then a JUnit) | reproduced | Two-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 | real | Own 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), Low | Replays 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), Low | Adjudication 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 validated | Stock 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 corrected | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 600 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,532 words
- 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).
- 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-172lucene/core/src/java/org/apache/lucene/index/StoredFields.java:29-30; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:45lucene/CHANGES.txt:1426; (at 5adff78bb168) lucene/core/src/test/org/apache/lucene/search/TestSegmentReordering.java:382https://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
- TestSegmentOrderConcurrentReorder.java (lucene/core, package org.apache.lucene.search, 1 test):
repro/LFV-1187/TestSegmentOrderConcurrentReorder.java(74 lines)
Drawn from the code at 5adff78bb168, JDK 25's HashMap source and the executed repro runs; the PNG is the summary still.
assets/LFV-1187/bug.png(600 KB)
