All findings · Lucene finding · LFV-296 · segmentorder-null-core-cache-helper-npe

SegmentOrder.reorder() throws NullPointerException when a leaf has no core cache helper

With a numeric sort, SegmentOrder.reorder() throws NullPointerException as soon as one leaf returns null from getCoreCacheHelper(), which LeafReader documents as legal ("not suited for caching"). Two one-document MemoryIndex readers in a MultiReader are enough; 10.4.0 to 10.6.0 and main are affected.

Summary

Priority
Low, rank 637The 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
crash
Since
10.4.0 (#15591, 2026), when SegmentOrder was addedintroduced 2026-01-23
Affected
10.4.0 to 10.6.0 and main
Status
documented
Found by
code reading of every core-cache-helper consumer, then a JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

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.

Background: SegmentOrder and core cache helpers

  1. 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.
  2. Every LeafReader has getCoreCacheHelper(), a key for caching per-segment data. null means the reader is "not suited for caching", typical for short-lived wrappers; consumers such as the query cache then just compute without caching.
A MultiReader with two MemoryIndex leaves, f = 1 and f = 2, both with getCoreCacheHelper() = null, passed to SegmentOrder.reorder with a descending LONG sort on f. Expected: a view with the f = 2 leaf first. Actual: the comparator calls getCoreCacheHelper().getKey() on a leaf whose helper is null and throws NullPointerException at SegmentOrder.java:137, before any view exists. A green box shows the fix: no memo when the helper is null.
The two-leaf repro: 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.

The finding, step by step

  1. Put two MemoryIndex readers in a MultiReader: one holds LongPoint f = 1, the other f = 2. A MemoryIndex reader returns null from getCoreCacheHelper(), which means "not suited for caching".
  2. 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.
  3. Instead it throws NullPointerException and returns no view. To order the 2 leaves, its comparator remembers each leaf's max f under reader.getCoreCacheHelper().getKey() (SegmentOrder.java:137) and never checks for null.
  4. The same happens with a ParallelCompositeReader over two indexes, SortingCodecReader leaves or a FilterLeafReader that returns null. A plain DirectoryReader works, and the query cache, given the same null, just skips caching.

Who is affected

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.

The mechanism (main 645d4fe14a20)

  1. One comparator per SegmentOrder. fromSort maps an INT, LONG, FLOAT or DOUBLE primary sort (also through SortedNumericSortField) to numericSorter (SegmentOrder.java:42-77); any other type returns the reader unchanged (:79-85). numericSorter creates one NumericFieldReaderContextComparator (:91-92) whose HashMap memo is never evicted (:115).
  2. reorder() sorts in a constructor. It copies the leaves into a new array and hands it, with the comparator, to a BaseCompositeReader (SegmentOrder.java:95-98), whose constructor first runs Arrays.sort on it (BaseCompositeReader.java:74-77).
  3. The unguarded key. compare calls getSortValue for both leaves (SegmentOrder.java:130-134), which starts with IndexReader.CacheKey key = reader.getCoreCacheHelper().getKey(); (:137). A leaf whose helper is null throws there, before loadSortValue (: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.
  4. null is a documented answer. LeafReader#getCoreCacheHelper: "A return value of null indicates 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 by ParallelLeafReader unless it has exactly one parallel reader that is also its only stored-fields reader (ParallelLeafReader.java:300-311), so by every leaf of a ParallelCompositeReader over two or more readers; by SortingCodecReader ("no caching on sorted views", SortingCodecReader.java:848-852, and :460-463); by MemoryIndex's reader (MemoryIndex.java:2296-2299); and by FilterLeafReaders that choose to (FilterLeafReader.java:40-42 only suggests delegating the helpers when a wrapper does not change the content).

When it happens and when not

  • It needs a numeric primary sort, at least two leaves (Arrays.sort never 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 DirectoryReader never 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 SegmentOrder except its own test; plain DirectoryReaders 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.

Minimal repro

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.

TestSegmentOrderNullCoreCacheHelper.java (JUnit test)46 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.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());
    }
  }
}
Command
# 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.

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

More files, environment, full output69 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected 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)
       [...]

Why it looks unintended

  • LeafReader#getCoreCacheHelper documents null as legal; reorder takes any IndexReader and 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 of null indicates 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 only throws IOException (:34) and documents no unchecked exception; fromSort says only that a non-numeric primary sort "will have no effect" (:39-40).
  • What null is meant to do. In LUCENE-7410 (#8463), Adrien Grand (@jpountz) wrote: "the query cache and BitSetProducer for joins skip caching when LeafReader.getCoreCacheHelper returns 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." LRUQueryCache skips caching (LRUQueryCache.java:827-831, :914-918); QueryBitSetProducer computes without caching (guarded lookup QueryBitSetProducer.java:63-68, guarded cache.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), QueryBitSetProducer and two test-framework classes (PerThreadPKLookup, AssertingLeafReader) all check for null; SegmentOrder.java:137 calls .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, null or wrapper readers. Its closed predecessor #15436 memoised by LeafReaderContext.ord. TestSegmentReordering opens its reader with DirectoryReader.open(dir), whose leaves always have a helper.
  • No test relies on it. With the fix, TestSegmentReordering and the whole lucene/memory suite 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.

Suggested fix

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.

Compute without memoising when the helper is null (validated by the checks) (recommended)

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 LRUQueryCache and QueryBitSetProducer do when the helper is null: 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 per reorder() (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) and TestSegmentReordering pass with assertions on and off: 5 suites, 55 tests, 0 failures each time (the repro 1, TestMemoryIndex 30, TestMemoryIndexAgainstDirectory 16, TestSlicedIntBlockPool 5, TestSegmentReordering 3).
  • 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 TestSegmentReordering passed 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, and TestSegmentReordering covers all four on cacheable leaves).

Key the memo by the leaf when the helper is null (validated by the checks)

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.

Memoise per reorder() call

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.

Refuse such readers with a descriptive exception and document it

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr; all of GitHub)2026-10-26a 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 PRsno report; 13 hits read, all about something else
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-268 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_6only #15591; one unrelated HNSW PR (#16906); line 137 unchanged

History

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).

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 releaseNPE at SegmentOrder.java:137
10.5.0, 10.5.1, 10.5.2 affectedthe same NPE (10.5.2 also with -da)
10.6.0 (4ae946e5a9b9) affectedthe same NPE (release jars)
main 645d4fe14a20 affectedthe same NPE, gradle and standalone, assertions on and off
upstream main 2f424fd428da, branch_10_6 source readline 137 unchanged (2026-10-30)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (code reading, then a JUnit) candidateParallelCompositeReader 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 verdictNo 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 realOwn 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 weakRoot 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 severityFound 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 lowRe-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 reproducesFinder 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 appliedIndependent, 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 validatedFresh 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).

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

Finding record

LFV-296, snapshot of 2026-11-06

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 whole lucene/memory suite and TestSegmentReordering, 55 tests, 0 failures, assertions on and off). The same day: a standalone JUnit runner on main with -ea and -da and 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 test with the fix. The hunt ran on main ea57360912ed, where SegmentOrder.java is the same; upstream main 2f424fd428da and branch_10_6 were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.

Lucene and upstream sources

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-172
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-77
  • lucene/core/src/java/org/apache/lucene/index/LeafReader.java:65-78
  • lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:300-311
  • lucene/core/src/java/org/apache/lucene/index/SortingCodecReader.java:458-464, 848-852
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:2296-2299
  • lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:37-42
  • lucene/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-78
  • lucene/core/src/java/org/apache/lucene/index/SegmentOrder.java:30-41, 137, 144-172
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:827-831, 914-918
  • lucene/join/src/java/org/apache/lucene/search/join/QueryBitSetProducer.java:60-89
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/PerThreadPKLookup.java:80-81; AssertingLeafReader.java:88-89
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/SegmentInfosSearcherManager.java:122-126
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed repro runs; the PNG is the summary card.

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.