SegmentOrder's leaf comparator reads each leaf's core cache key without checking for null, so one leaf that is "not suited for caching" turns reorder() into a crash.
SegmentOrder.fromSort(sort).reorder(reader)(new in 10.4.0) returns a view of the same segments in a new order: for a numeric sort, the segments with the best values first, so a sorted top-k search can skip the rest early.- Every
LeafReaderhasgetCoreCacheHelper(), a key for caching per-segment data.nullmeans the reader is "not suited for caching", typical for short-lived wrappers; consumers such as the query cache then just compute without caching.
reorder() sorts the leaves by their max f and looks up each leaf's core cache key; the MemoryIndex leaf's null helper throws at SegmentOrder.java:137. Select the still to open it at full size. Generated with AI, checked against the code.- Put two
MemoryIndexreaders in aMultiReader: one holdsLongPointf = 1, the other f = 2. AMemoryIndexreader returnsnullfromgetCoreCacheHelper(), which means "not suited for caching". - Call
SegmentOrder.fromSort(new Sort(new SortField("f", SortField.Type.LONG, true))).reorder(multi). You expect a view of the same 2 leaves with the f = 2 leaf first, so a sorted search can stop early. - Instead it throws
NullPointerExceptionand returns no view. To order the 2 leaves, its comparator remembers each leaf's max f underreader.getCoreCacheHelper().getKey()(SegmentOrder.java:137) and never checks fornull. - The same happens with a
ParallelCompositeReaderover two indexes,SortingCodecReaderleaves or aFilterLeafReaderthat returnsnull. A plainDirectoryReaderworks, and the query cache, given the samenull, just skips caching.
Code that calls the opt-in SegmentOrder helper (10.4.0 and later; nothing in Lucene calls it) with an INT, LONG, FLOAT or DOUBLE sort on a reader with a non-cacheable leaf: a ParallelCompositeReader over 2+ readers, SortingCodecReader or MemoryIndex leaves, or per-request FilterLeafReaders that return null. Every such call fails at once; no wrong results.
Mechanism, terms and numbers511 words
SegmentOrder (new in 10.4.0, #15591) re-orders a reader's segments so that the segments with the best values of a numeric sort are searched first. With an INT, LONG, FLOAT or DOUBLE primary sort, reorder() sorts the leaves with a comparator that memoises each leaf's min/max value under reader.getCoreCacheHelper().getKey() (SegmentOrder.java:137) without checking for null. LeafReader#getCoreCacheHelper documents null as a legal answer, and ParallelLeafReader over more than one reader, SortingCodecReader and MemoryIndex return it, as content-altering FilterLeafReaders typically do. So reorder() on a reader with two or more leaves, one of them non-cacheable, throws NullPointerException inside the BaseCompositeReader constructor before any view exists. Every other consumer of a leaf's core cache helper in the main sources checks for null; LRUQueryCache and QueryBitSetProducer then compute without caching. No wrong results; 10.4.0 to 10.6.0 and main are affected, and it is not fixed upstream.
- One comparator per
SegmentOrder.fromSortmaps an INT, LONG, FLOAT or DOUBLE primary sort (also throughSortedNumericSortField) tonumericSorter(SegmentOrder.java:42-77); any other type returns the reader unchanged (:79-85).numericSortercreates oneNumericFieldReaderContextComparator(:91-92) whoseHashMapmemo is never evicted (:115). reorder()sorts in a constructor. It copies the leaves into a new array and hands it, with the comparator, to aBaseCompositeReader(SegmentOrder.java:95-98), whose constructor first runsArrays.sorton it (BaseCompositeReader.java:74-77).- The unguarded key.
comparecallsgetSortValuefor both leaves (SegmentOrder.java:130-134), which starts withIndexReader.CacheKey key = reader.getCoreCacheHelper().getKey();(:137). A leaf whose helper isnullthrows there, beforeloadSortValue(:144-172) reads any field. Every recorded stack (all from descending sorts; an ascending sort goes through:133):getSortValue:137 <- compare:132 <- Arrays.sort <- BaseCompositeReader.<init>:76 <- reorder:97. nullis a documented answer.LeafReader#getCoreCacheHelper: "A return value ofnullindicates that this reader is not suited for caching, which is typically the case for short-lived wrappers that alter the content of the wrapped leaf reader" (LeafReader.java:72-74). It is returned byParallelLeafReaderunless it has exactly one parallel reader that is also its only stored-fields reader (ParallelLeafReader.java:300-311), so by every leaf of aParallelCompositeReaderover two or more readers; bySortingCodecReader("no caching on sorted views",SortingCodecReader.java:848-852, and:460-463); byMemoryIndex's reader (MemoryIndex.java:2296-2299); and byFilterLeafReaders that choose to (FilterLeafReader.java:40-42only suggests delegating the helpers when a wrapper does not change the content).
- It needs a numeric primary sort, at least two leaves (
Arrays.sortnever calls the comparator for one element) and at least one compared leaf without a core cache helper. - A single leaf, a non-numeric sort or a plain
DirectoryReadernever fails; the control re-orders a 2-segment index to leaf maxima [109, 9]. - The sort field and the data play no role: 4 numeric types x {an indexed field, a field absent from the index} x both directions gave the same NPE in all 16 cases.
- Deterministic: 20 of 20 iterations fail.
Exposure and likelihood202 words
- Who is exposed. Callers of
SegmentOrder.fromSort(<INT|LONG|FLOAT|DOUBLE sort>).reorder(reader)whose reader has two or more leaves and at least one leaf without a core cache helper. Per-request wrappers are the typical case: LUCENE-7410 (#8463), which made the helper nullable, describes applications that "wrap it per request depending on the logged user and throw away the wrapper once the request is completed". - Default vs opt-in. Opt-in: nothing in Lucene calls
SegmentOrderexcept its own test; plainDirectoryReaders are never affected. - Effect. The call throws before any view exists. The caller gets no reader; the input reader and its reference count are unchanged (executed probe). Such an application cannot use numeric segment reordering on those readers (a non-numeric sort or a single leaf never reaches the comparator); it has to order the leaves itself. A working re-ordered view would hold the same documents with different composite document ids, since the ids follow the new leaf order (
BaseCompositeReader.java:81-87); that is by design. - Scale. Two leaves with one value each are enough.
- Reach. A read-only code search found no use in Elasticsearch, OpenSearch or Solr (not exhaustive). The failure is immediate whenever this path executes; how many applications reach it is unmeasured. Likelihood (registry): rare.
The test shows that SegmentOrder.fromSort(LONG sort on f, descending).reorder(reader) on a MultiReader over two MemoryIndex readers throws NullPointerException; their leaves return null from getCoreCacheHelper(), which LeafReader documents as legal.
/*
* 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.index;
import org.apache.lucene.document.LongPoint;
import org.apache.lucene.index.memory.MemoryIndex;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSegmentOrderNullCoreCacheHelper extends LuceneTestCase {
public void testReorderLeavesWithoutCoreCacheHelper() throws Exception {
MemoryIndex low = new MemoryIndex();
low.addField(new LongPoint("f", 1), null);
MemoryIndex high = new MemoryIndex();
high.addField(new LongPoint("f", 2), null);
IndexReader highReader = high.createSearcher().getIndexReader();
try (MultiReader multi =
new MultiReader(low.createSearcher().getIndexReader(), highReader)) {
Sort sort = new Sort(new SortField("f", SortField.Type.LONG, true));
IndexReader reordered;
// expected leaves ordered 2, 1; actual NPE (MemoryIndex leaves have a null core cache helper)
try {
reordered = SegmentOrder.fromSort(sort).reorder(multi);
} catch (NullPointerException e) {
throw new AssertionError("expected the leaves ordered by f descending (2, 1), got " + e, e);
}
assertSame(highReader, reordered.leaves().get(0).reader());
}
}
}
# in a copy of this finding's repro folder (repro/LFV-296/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.index.TestSegmentOrderNullCoreCacheHelper
- Expected vs actual
- Expected: a reordered view with the f=2 leaf first; actual (-ea and -da): NPE at SegmentOrder.java:137 (
reader.getCoreCacheHelper().getKey()without a null check), reported as an AssertionError that states both.
More files, environment, full output69 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected the leaves ordered by f descending (2, 1), got java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.IndexReader$CacheHelper.getKey()" because the return value of "org.apache.lucene.index.LeafReader.getCoreCacheHelper()" is null
org.apache.lucene.index.TestSegmentOrderNullCoreCacheHelper.testReorderLeavesWithoutCoreCacheHelper(TestSegmentOrderNullCoreCacheHelper.java:41)
Caused by: java.lang.NullPointerException: Cannot invoke "org.apache.lucene.index.IndexReader$CacheHelper.getKey()" because the return value of "org.apache.lucene.index.LeafReader.getCoreCacheHelper()" is null
org.apache.lucene.index.SegmentOrder$NumericFieldReaderContextComparator.getSortValue(SegmentOrder.java:137)
org.apache.lucene.index.SegmentOrder$NumericFieldReaderContextComparator.compare(SegmentOrder.java:132)
org.apache.lucene.index.SegmentOrder$NumericFieldReaderContextComparator.compare(SegmentOrder.java:113)
java.base/java.util.TimSort.countRunAndMakeAscending(TimSort.java:355)
java.base/java.util.TimSort.sort(TimSort.java:220)
[...]LeafReader#getCoreCacheHelperdocumentsnullas legal;reordertakes anyIndexReaderand documents no restriction.- Every other consumer of a leaf's core cache helper checks for
null; APIs that must refuse such leaves throw a descriptive exception, not an NPE. - The introducing PR #15591 never discusses cache helpers; its only test uses plain
DirectoryReaders.
The full argument247 words
- Contract.
LeafReader#getCoreCacheHelper: "Optional method ... A return value ofnullindicates that this reader is not suited for caching" (LeafReader.java:65-74).SegmentOrder#reorder: "Produces a new view over an IndexReader by re-ordering the reader's segments" (SegmentOrder.java:33), no restriction on the reader; it declares onlythrows IOException(:34) and documents no unchecked exception;fromSortsays only that a non-numeric primary sort "will have no effect" (:39-40). - What
nullis meant to do. In LUCENE-7410 (#8463), Adrien Grand (@jpountz) wrote: "the query cache and BitSetProducer for joins skip caching whenLeafReader.getCoreCacheHelperreturns null. Other APIs like segment-based replication or FieldCache fail with an exception since not being able to cache is a problem/performance trap in those cases."LRUQueryCacheskips caching (LRUQueryCache.java:827-831,:914-918);QueryBitSetProducercomputes without caching (guarded lookupQueryBitSetProducer.java:63-68, guardedcache.put:84-86). - The only unguarded consumer. Outside wrappers that just delegate, the main sources use a leaf's core cache helper in six places:
LRUQueryCache(twice),QueryBitSetProducerand two test-framework classes (PerThreadPKLookup,AssertingLeafReader) all check fornull;SegmentOrder.java:137calls.getKey()on it directly. - History. #15591 (@romseygeek, merged 2026-01-23) added the memo as written; its body, review comments and reviews never mention cache helpers,
nullor wrapper readers. Its closed predecessor #15436 memoised byLeafReaderContext.ord.TestSegmentReorderingopens its reader withDirectoryReader.open(dir), whose leaves always have a helper. - No test relies on it. With the fix,
TestSegmentReorderingand the wholelucene/memorysuite pass.
The devil's-advocate check170 words
Strongest case (from the verifiers and the adjudication): LUCENE-7410 lets APIs for which "not being able to cache is a problem" refuse leaves without a helper; SegmentOrder memoises per core key, so it is such an API, and it was written for plain DirectoryReaders.
Why it fails: the APIs that refuse do it on purpose with a descriptive exception (for example "StandardDirectoryReader must support caching", SegmentInfosSearcherManager.java:123-126), never with an NPE; SegmentOrder documents no such restriction and accepts any IndexReader; its memo only saves a per-leaf doc-values-skipper or point min/max lookup (SegmentOrder.java:144-172), so computing without it gives the same order (the fix passes the repro and TestSegmentReordering); and for non-numeric sorts the class already degrades to a no-op instead of rejecting input. No verifier rated the intended argument better than weak. To concede: an explicit, documented rejection of such readers would be a legitimate design choice (LUCENE-7410 allows it; the refuse option under Fixes); what is hard to defend is the current accidental, undocumented NPE.
Recommended Compute without memoising when the helper is null
In SegmentOrder's comparator, when getCoreCacheHelper() is null, return loadSortValue(reader) without memoising it (+7/-1, one file), as the query cache does on null. With it the repro, TestSegmentReordering and the whole lucene/memory suite pass (55 tests), with assertions on and off.
private long getSortValue(LeafReader reader) {
IndexReader.CacheHelper cacheHelper = reader.getCoreCacheHelper();
if (cacheHelper == null) {
// not suited for caching: compute the value without memoizing it, so that short-lived
// wrappers are not kept alive by this (never evicted) map
return loadSortValue(reader);
}
IndexReader.CacheKey key = cacheHelper.getKey();
if (cachedSortValues.containsKey(key) == false) {
cachedSortValues.put(key, loadSortValue(reader));
}
return cachedSortValues.get(key);
}All options and trade-offs4 options ยท 638 words
The recommended fix was applied to 645d4fe14a20 with git apply in a fresh scratch worktree and run through gradle. A second option also removes the NPE but keeps short-lived wrappers alive; two alternatives were not run.
In NumericFieldReaderContextComparator.getSortValue (SegmentOrder.java:136-142), read the helper once; when it is null, return loadSortValue(reader) directly; otherwise memoise under cacheHelper.getKey() as today.
private long getSortValue(LeafReader reader) {
IndexReader.CacheHelper cacheHelper = reader.getCoreCacheHelper();
if (cacheHelper == null) {
// not suited for caching: compute the value without memoizing it, so that short-lived
// wrappers are not kept alive by this (never evicted) map
return loadSortValue(reader);
}
IndexReader.CacheKey key = cacheHelper.getKey();
if (cachedSortValues.containsKey(key) == false) {
cachedSortValues.put(key, loadSortValue(reader));
}
return cachedSortValues.get(key);
}Trade-offs
- Does what
LRUQueryCacheandQueryBitSetProducerdo when the helper isnull: compute without caching. - Cost: for a leaf without a helper,
loadSortValue(a doc-values-skipper or point min/max lookup) runs on every comparison that involves it, so up to O(n log n) such lookups for n leaves perreorder()(TimSort makes between n - 1 and O(n log n) comparisons, depending on the input), instead of at most one per leaf. Cacheable leaves are unchanged. No benchmark was run.
What the checks validated
- Gradle, fresh scratch worktree of
645d4fe14a20+ the test (2026-10-30): stock, the repro fails 1 of 1 with assertions on and off; + fix,:lucene:memory:test(whole module) andTestSegmentReorderingpass with assertions on and off: 5 suites, 55 tests, 0 failures each time (the repro 1,TestMemoryIndex30,TestMemoryIndexAgainstDirectory16,TestSlicedIntBlockPool5,TestSegmentReordering3). - Earlier the same day, + fix:
:lucene:core:check :lucene:memory:check -x test(google-java-format, ecjLint, forbiddenApis) passes with the test in the tree. - Standalone runner with the fixed class shadowed: the repro passes; earlier the finder's repro, a verifier's 4 tests and
TestSegmentReorderingpassed with it (8 of 8), and the re-ordered wrappers were garbage-collected. - Not run: the full Lucene test suite, nightly tests, any benchmark, and any post-fix test of an INT, FLOAT or DOUBLE sort over leaves without a helper (every post-fix null-helper test uses LONG; all four types go through the same
getSortValue, andTestSegmentReorderingcovers all four on cacheable leaves).
The finder's sketch: Object key = cacheHelper == null ? reader : cacheHelper.getKey();.
Trade-offs
Removes the NPE and passes the finder's repro and TestSegmentReordering, but the memo lives as long as the SegmentOrder and is never evicted, so every non-cacheable wrapper ever re-ordered stays strongly reachable: in a probe two re-ordered wrappers stayed reachable after repeated GC while an identical control wrapper was collected. Those are exactly the short-lived wrappers the null helper exists for; growth beyond the two probed wrappers is inferred from the never-evicted map, not measured.
What the checks validated
Finder's repro 1 of 1 and TestSegmentReordering 3 of 3 pass; retention probe: retained.
Compute each leaf's value once per reorder() call (for example into a local identity map or an array before sorting) for leaves without a helper. Not run.
Trade-offs
Avoids both the repeated lookups of the recommended fix and the retention of the key-by-leaf fix, but changes how the comparator is built (today one comparator per SegmentOrder is passed to the BaseCompositeReader constructor), so it is a larger change.
Check the helpers up front and throw IllegalArgumentException with a clear message; document it. Not run.
Trade-offs
Matches LUCENE-7410's "fail with an exception" option, but correctness does not need it: without the memo the same order comes out. The cost of the extra lookups was not measured, and other LeafReader implementations may make the min/max lookup more expensive than the readers tested here.
Products that wrap readers per request in non-cacheable filters (for example per-user document filters) cannot use numeric SegmentOrder reordering on them until this is fixed; queries and documents cannot trigger it.
Code reading: while listing every consumer of a leaf's core cache helper across all modules, the hunt saw that each one checks for null except SegmentOrder's comparator. A public-API JUnit then showed the crash on main and on the 10.4.0 and 10.5.2 release jars.
The method in full104 words
Method: non-formal (code reading, then a directed JUnit), a hunt unit on query-cache correctness, 2026-10-26, main ea57360912ed. The unit listed every getCoreCacheHelper() and getReaderCacheHelper() consumer in all module sources. LRUQueryCache, QueryBitSetProducer and the others check for null; SegmentOrder.java:137 does not. The finder's JUnit wraps two 2-segment indexes in ParallelCompositeReader(false, r1, r2), asserts that the leaves have a null helper, and re-orders with a descending LONG sort: it fails 1 of 1 with -ea and -da, 20 of 20 over 20 iterations, and on 10.4.0 and 10.5.2. A later minimisation step replaced it with the two-leaf MemoryIndex test shown here.
No prior report found in GitHub issues, PRs and commits (Lucene and three downstream projects), all open PRs, the mailing lists and the web: 133 queries on 2026-10-26, refreshed twice on 2026-10-30. Only the introducing PR #15591 mentions SegmentOrder.
Searches3 searches
Context, not reports: #15591 (the introducing PR; no cache-helper discussion); #15436 (its closed predecessor, memoised by LeafReaderContext.ord); #8463 / LUCENE-7410 (origin of the nullable helper and the skip-caching convention); #10546 / LUCENE-9507 (the BaseCompositeReader leaf comparator that SegmentOrder plugs into); OpenSearch #20896 (a generic automated review remark that wrappers returning null helpers can cause NPEs in Lucene internals; it names no Lucene class). JIRA cannot hold a report: the class dates from 2026. Search limits: keyword search only, GitHub code search is partial, private channels are not searchable.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr; all of GitHub) | 2026-10-26 | a scripted prior-report search run (52 queries over GitHub, the lists, the JIRA archive and the web), 65 GraphQL searches, 2 code searches, the history of SegmentOrder.java on five branches, the changed files of all 411 open PRs | no report; 13 hits read, all about something else |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 8 direct archive queries (SegmentOrder, getCoreCacheHelper) | only #15591 notification mails |
| GitHub refresh (apache/lucene) | 2026-10-30 (twice) | issues and PRs mentioning SegmentOrder; getCoreCacheHelper null / NullPointerException; reorder NullPointerException; commits touching SegmentOrder.java on main and branch_10_6 | only #15591; one unrelated HNSW PR (#16906); line 137 unchanged |
Introduced in 10.4.0 by #15591 (d455fefc6b63, 2026-01-23), which added SegmentOrder with the unguarded key; the nullable-helper contract is older (LUCENE-7410, 7.0.0). Affects 10.4.0 to 10.6.0 and main; not fixed upstream.
Versions tested6 rows
#15591 "Add SegmentOrder utility class for per-query segment re-ordering" (@romseygeek) is d455fefc6b63 on main and 7b71560832c6 on branch_10x (both 2026-01-23); CHANGES 10.4.0, New Features. git blame of SegmentOrder.java:137 points at d455fefc6b63; the only later commit to the file, #16388 (1593f87193b8), did not touch getSortValue. getCoreCacheHelper may return null, and ParallelLeafReader does over more than one reader, since LUCENE-7410 (df6f8307230, 2017, 7.0.0). Upstream main 2f424fd428da (2026-10-30) and branch_10_6 still have line 137; branch_10x was decommissioned on 2026-10-29 (#16911).
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.3, 10.3.2 | API absent | SegmentOrder does not exist (compile error) |
| 10.4.0 | first release | NPE at SegmentOrder.java:137 |
| 10.5.0, 10.5.1, 10.5.2 | affected | the same NPE (10.5.2 also with -da) |
10.6.0 (4ae946e5a9b9) | affected | the same NPE (release jars) |
main 645d4fe14a20 | affected | the same NPE, gradle and standalone, assertions on and off |
upstream main 2f424fd428da, branch_10_6 | source read | line 137 unchanged (2026-10-30) |
The finder, independent verifiers and an adjudication rated it real after re-running repros; a reading-only cross-model review agreed. The cross-model refute review never produced a verdict. Re-verified on main 645d4fe14a20 and 10.6.0; this doc's gradle runs reproduced it and validated the fix.
Checks and verdicts423 words
Every executed check reproduced the same NPE at SegmentOrder.java:137. The refute-lens review by an independent reviewer from another model family (OpenAI GPT-5.6) was attempted four times: twice on 2026-10-26 (no verdict) and twice on 2026-10-30, when it could not run any command and returned "not real" with low confidence, which its notes call procedural, not a disproof. Earlier drafts of this doc went through an independent fact check, a cross-model fact check and a diagram check, two rounds each, on 2026-10-30; their accepted corrections are in this version.
| Check | Verdict | What it found |
|---|---|---|
| finder (code reading, then a JUnit) | candidate | ParallelCompositeReader JUnit fails 1 of 1 on main (-ea, -da), 10.4.0 and 10.5.2, 20 of 20 iterations. |
| cross-model review, refute lens (OpenAI GPT-5.6) | no substantive verdict | No verdict (2026-10-26, twice); could not run any command, a procedural "not real" with low confidence (2026-10-30, twice). In both 2026-10-30 attempts a separate re-run of the finder's repro failed (1 of 1, NPE at SegmentOrder.java:137); the 2026-10-26 records hold no re-run. |
| Independent reproducer | real | Own 4-test JUnit: 3 null-helper triggers fail on main (-ea, -da), 10.5.2 and 10.4.0; the DirectoryReader control passes with order [109, 9]. |
| Cross-model review, intended lens (OpenAI GPT-5.6, reading only) | real, medium; intended argument weak | Root cause agreed; flagged that keying the memo by the leaf keeps wrappers alive; it may have seen the independent reproduction's verdict. |
| Intended-behaviour devil's advocate | real, low severity | Found no evidence that the current undocumented NPE is intended, documented, a misuse or unreachable (maintainers could still choose an explicit documented rejection); added the MemoryIndex trigger. |
| adjudication | confirmed, high; severity low | Re-ran the finder's repro and the independent reproduction's JUnit with a new seed, -ea and -da: same NPE, control passes. |
| re-verify on main 645d4fe14a20 and 10.6.0 | still reproduces | Finder 1 of 1 (-ea, -da), the independent reproduction 3 of 4, the intended-behaviour check 1 of 1, on main and on 10.6.0-SNAPSHOT jars built locally at the release commit 4ae946e5a9b9 (this doc's run on the published 10.6.0 jars is under History); the recommended fix shadowed passes. |
| fact checks of earlier drafts | corrections applied | Independent, cross-model and diagram checks (2026-10-30, two rounds each); every accepted correction from both rounds is in this version; rejected ones are argued in the hunt's record. |
| this doc's runs | reproduced, fix validated | Fresh scratch worktree, gradle: stock fails with assertions on and off; with the fix the repro, lucene/memory and TestSegmentReordering pass (55 tests, 0 failures, both). |
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 435 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,134 words
- Registry title
- SegmentOrder.fromSort(numeric sort).reorder(reader) throws NullPointerException when a leaf's getCoreCacheHelper() returns null, which LeafReader documents as legal (ParallelCompositeReader over two or more readers, SortingCodecReader or MemoryIndex leaves, content-altering FilterLeafReaders)
- Area
- core / index: SegmentOrder (query-time segment re-ordering) and leaf core cache helpers
- Priority
- Low, rank 637: The new opt-in SegmentOrder helper throws an NPE right away on readers with non-cacheable leaves, a crash that has no in-tree or downstream caller and shows up in testing, so it sits low among the loud crashes.
- Impact class
- crash
- Impact note
- Only applications that call the opt-in SegmentOrder helper (added in 10.4.0; nothing in Lucene calls it) with an INT, LONG, FLOAT or DOUBLE primary sort on a reader with two or more leaves, at least one of which returns a null core cache helper (ParallelCompositeReader over two or more readers, MultiReader over SortingCodecReader or MemoryIndex leaves, a FilterLeafReader that declares itself not suited for caching); every such reorder() call fails at once with a NullPointerException before any view exists, with no wrong results; plain DirectoryReaders are not affected; released in 10.4.0 to 10.6.0 (10.6.0 executed on the RC1 jars).
- Summary
- You call SegmentOrder.fromSort(new Sort(new SortField("f", SortField.Type.LONG, true))).reorder(reader) to visit the segments with the highest f values first, on a reader whose leaves do not support caching, for example a ParallelCompositeReader over two DirectoryReaders with two segments each. You expect a re-ordered view, because reorder() accepts any IndexReader and LeafReader#getCoreCacheHelper documents null as a legal return value ('not suited for caching') that other consumers such as LRUQueryCache and QueryBitSetProducer simply skip. Instead reorder() throws NullPointerException at SegmentOrder.java:137, where the leaf comparator's memo calls reader.getCoreCacheHelper().getKey() without a null check.
- Introduced
- d455fefc6b63f90216c938fda061286918fee37e on main and its branch_10x backport 7b71560832c6, 'Add SegmentOrder utility class for per-query segment re-ordering', apache/lucene#15591 by romseygeek [read: git blame of SegmentOrder.java:137 in the full-history clone points at d455fefc6b63; the backport is in the 10.4.0 to 10.5.2 tags; exec: the repro does not compile on 10.3.2 and fails on 10.4.0]. Not a regression: the unguarded key has been there since the class was created; the contract it misses is older (LeafReader#getCoreCacheHelper may return null and ParallelLeafReader returns null over more than one reader since LUCENE-7410, df6f8307230, 2017, 7.0.0) (2026-01-23 (#15591 merged; backport committed the same day))
- First affected version
- 10.4.0 [exec] (first release with SegmentOrder; 10.3.2 and 9.12.3 give compile_error because the class is absent)
- Affected versions
- 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main ea57360912ed [exec] (the finder's JUnit fails 1 of 1 on each with the same NPE at SegmentOrder.java:137; 10.5.2 and main also with -da; the independent reproduction's JUnit fails 3 of 4 on main -ea/-da, 10.5.2 and 10.4.0); 10.6.0 (released 2026-10-29; branch_10_6 RC1 4ae946e5a9b) [exec 2026-10-29: finder 1 of 1 with -ea and -da, the independent reproduction 3 of 4, the intended-behaviour check 1 of 1] and branch_10x 657014b3c579 [read: same sources on this path]; main 645d4fe14a2 [exec 2026-10-29, -ea and -da]. Not affected: every release up to 10.3.2 and all 9.x (API absent). Not fixed upstream: line 137 is unchanged on upstream main 645d4fe14a2 (2026-10-29), branch_10x 657014b3c579 and branch_10_6, and no open PR touches SegmentOrder.java [read].
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record, verdict new: 133 recorded queries: scripted prior-report search v1 (52: GitHub issues/PRs and commits on apache/lucene, Elasticsearch/OpenSearch/Solr, dev@, java-user@, issues@ and commits@, the JIRA archive, web), 65 read-only GitHub GraphQL searches over apache/lucene, Elasticsearch, OpenSearch, Solr and all of GitHub, 8 direct mailing-list queries, 2 GitHub code searches, upstream commit history of SegmentOrder.java on main, branch_10x, branch_10_6, branch_10_5 and branch_10_4, and the changed-file lists of all 411 open apache/lucene PRs; the finder's earlier scripted prior-report search found the same); not fixed upstream (line 137 unchanged on main e1c4184cb98, branch_10x and branch_10_6; no open PR touches SegmentOrder.java or TestSegmentReordering.java). Related, not reports of this bug: #15591 (the introducing PR; its review never mentions cache helpers), #15436 (closed predecessor, memoised by LeafReaderContext.ord), #8463 / LUCENE-7410 (origin of the nullable getCoreCacheHelper contract), #10546 / LUCENE-9507 (the BaseCompositeReader leaf comparator), OpenSearch #20896 (a generic automated review remark that null cache helpers can cause NullPointerExceptions in Lucene internals; names no Lucene class)
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT, commit of 2026-10-28) in a fresh scratch worktree through gradle (2026-10-30): the repro test stock (fails, assertions on and off) and with the recommended fix (passes; with the fix also the wholelucene/memorysuite andTestSegmentReordering, 55 tests, 0 failures, assertions on and off). The same day: a standalone JUnit runner on main with-eaand-daand on the 10.4.0, 10.5.2 and 10.6.0 release jars (all fail; 10.3.2 lacks the class), and:lucene:core:check :lucene:memory:check -x testwith the fix. The hunt ran on mainea57360912ed, whereSegmentOrder.javais the same; upstream main2f424fd428daandbranch_10_6were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.
- Finding record
lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:30-41 (class and method javadoc, no input restriction), :79-85 (non-numeric sort returns the reader unchanged), :89-111 (numericSorter: one comparator per SegmentOrder, BaseCompositeReader over a fresh leaf array, getReaderCacheHelper delegates to the original reader), :113-115 (comparator class and its never-evicted HashMap memo), :136-142 (getSortValue, unguarded reader.getCoreCacheHelper().getKey() at :137)lucene/core/src/java/org/apache/lucene/index/LeafReader.java:63-77 (getCoreCacheHelper: 'A return value of null indicates that this reader is not suited for caching'), ParallelLeafReader.java:300-311 (null unless it wraps one reader), SortingCodecReader.java:848-852 ('no caching on sorted views', returns null), BaseCompositeReader.java:74-77 (Arrays.sort of the leaves with the comparator in the constructor)lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:2296-2298 (getCoreCacheHelper returns null); lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:827-831 and lucene/join/src/java/org/apache/lucene/search/join/QueryBitSetProducer.java:63-66 (sibling consumers check for null)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:30-42, 42-87, 89-111, 113-115, 130-142, 144-172lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-77lucene/core/src/java/org/apache/lucene/index/LeafReader.java:65-78lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:300-311lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:458-464, 848-852lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:2296-2299lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:37-42lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:827-831 (query cache skips caching on null)
- Who is affected
https://github.com/apache/lucene/issues/8463 (LUCENE-7410 issue body: per-request wrappers)grep -rl SegmentOrder over lucene/ --include='*.java' (SegmentOrder.java and TestSegmentReordering.java only)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/LeafReader.java:65-78lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:30-41, 137, 144-172lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:827-831, 914-918lucene/join/src/java/org/apache/lucene/search/join/QueryBitSetProducer.java:60-89lucene/test-framework/src/java/org/apache/lucene/tests/index/PerThreadPKLookup.java:80-81; AssertingLeafReader.java:88-89lucene/replicator/src/java/org/apache/lucene/replicator/nrt/SegmentInfosSearcherManager.java:122-126lucene/core/src/test/org/apache/lucene/search/TestSegmentReordering.java:71-89 (DirectoryReader.open; all four numeric types)grep -rn 'getCoreCacheHelper()' over lucene/*/src/java (consumers listed; 'getCoreCacheHelper()\.' has one hit: SegmentOrder.java:137)https://github.com/apache/lucene/issues/8463 (comment by Adrien Grand, 2016-08-10, migrated from JIRA)
- History
lucene/CHANGES.txt:1389 (10.4.0, New Features)
- Diagrams
lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:136-142
- TestSegmentOrderNullCoreCacheHelper.java (JUnit test):
repro/LFV-296/src/org/apache/lucene/index/TestSegmentOrderNullCoreCacheHelper.java(46 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed repro runs; the PNG is the summary card.
assets/LFV-296/bug.png(435 KB)
