All findings · Lucene finding · LFV-392 · bytesrefarray-iterator-null-comparator-npe

BytesRefArray.iterator(Comparator) throws NullPointerException for a null comparator instead of iterating in append order

BytesRefArray.iterator(Comparator) documents that a null comparator returns the values in append order, but since 8.5.0 it always sorts, so with 2 or more values a null comparator throws NullPointerException. Lucene never supplies one; only applications passing null, directly or via InMemorySorter or OfflineSorter, are hit.

Summary

Priority
Low, rank 1132The internal BytesRefArray API throws NullPointerException at once, and only for an explicit typed null comparator that no known caller passes, so it is a contract-only edge case.
Impact
minor
Since
8.5.0 (LUCENE-9228, commit a0b8f5c7c28, 2020-02-20)introduced 2020-02-20 · about 6 years
Affected
8.5.0-10.6.0 and main
Status
documented
Found by
non-formal: code reading while writing a javadoc-based model of BytesRefArray

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

Background: BytesRefArray and its iterators

  1. BytesRefArray is an internal, append-only list of byte strings (BytesRef). Lucene uses it as an in-memory buffer, for example in OfflineSorter and when buffering doc-values updates.
  2. It has three iterators. iterator() returns the values in append order. iterator(Comparator) returns them sorted by the comparator, or, by its javadoc, in append order when the comparator is null. iterator(SortState) replays a sort computed earlier, or append order for null.
  3. Sorting goes through StringSorter: a radix sort for a BytesRefComparator, otherwise a comparison sort that calls the comparator on pairs of values.
Diagram of the bug. A BytesRefArray holds two values, b then a. iterator((Comparator<BytesRef>) null) does not take the append-order path that iterator() and iterator(SortState) use; it calls sort(null, false), which hands null to a StringSorter. A null comparator is not a BytesRefComparator, so the comparison sort runs and calls cmp.compare on the first pair (StringSorter.java:102), which throws NullPointerException. Expected: b, a. Actual: NullPointerException.
How a null comparator travels from iterator(Comparator) into the sort instead of selecting append order, and where the first comparison of the two values throws; then what you expect and what you get. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Append two values to a BytesRefArray, b then a, and call array.iterator((Comparator<BytesRef>) null). The cast is needed: since 8.5 a bare iterator(null) does not compile, because two overloads match.
  2. You expect b, then a. The javadoc says "If a non null Comparator is provided the iterator will iterate the byte values in the order specified by the comparator. Otherwise the order is the same as the values were appended." Releases 8.0.0 to 8.4.1 do exactly that.
  3. Instead the call throws NullPointerException: Cannot invoke "java.util.Comparator.compare(Object, Object)" because "this.val$cmp" is null, from StringSorter, with assertions on and off.
  4. Why: the body is return iterator(sort(comp, false)); (BytesRefArray.java:200), a sort even for null. A null comparator falls to StringSorter's comparison sort, which calls cmp.compare on the first pair (StringSorter.java:102). With 0 or 1 values nothing is compared, so it takes 2.
  5. LUCENE-9228 (8.5.0) removed the old comp == null ? null : sort(comp) check and left the javadoc as it was. Every release from 8.5.0 to 10.6.0 throws; the no-arg iterator() is not affected.

Who is affected

Only an application that itself supplies a null comparator ("no sorting") on 2 or more values: to this internal utility, or to the internal InMemorySorter or OfflineSorter, which pass it on. Lucene's own code never does; no null call of BytesRefArray.iterator was found in Solr, Elasticsearch or OpenSearch. The call fails at once; no results are wrong. Rare.

Mechanism, terms and numbers618 words

BytesRefArray (core, @lucene.internal, @lucene.experimental) is an append-only list of byte strings. Its iterator(Comparator) javadoc says a non-null comparator gives sorted order and otherwise "the order is the same as the values were appended" (BytesRefArray.java:192-194), and iterator() is documented as "sugar for iterator(Comparator) with a null comparator" (:183). Since 8.5.0 the body is an unconditional sort, return iterator(sort(comp, false)); (:200). A null comparator is not a BytesRefComparator, so StringSorter takes its comparison sort, which calls cmp.compare at the first comparison (StringSorter.java:52-57, :102): with 2 or more values the call throws NullPointerException; with 0 or 1 values nothing is compared. Until 8.4.1 the body was comp == null ? null : sort(comp), and iterator() itself called iterator(null), so this path ran on every iterator() call for 8 years; LUCENE-9228 (a0b8f5c7c28, a doc-values update performance change) removed the check, moved iterator() to a new iterator((SortState) null) overload and left both javadocs unchanged. No Lucene code supplies a null comparator (OfflineSorter and InMemorySorter pass on the comparator they were built with, never null in the tree, and FieldUpdatesBuffer passes a SortState), so it went unnoticed; an application can still reach it by giving either sorter a null comparator. Executed: 8.0.0-8.4.1 iterate in append order, every release from 8.5.0 to 10.6.0 and main throws. A one-line null check restores the documented behaviour; correcting the javadoc is the alternative a maintainer may prefer.

Terms

  • BytesRefArray (org.apache.lucene.util, core): an append-only list of BytesRef values stored in a ByteBlockPool. Marked @lucene.internal and @lucene.experimental (BytesRefArray.java:29-30).
  • StringSorter (org.apache.lucene.util, core): the sorter BytesRefArray.sort uses since 9.9.0 (#12591). It radix-sorts when the comparator is a BytesRefComparator and otherwise falls back to an IntroSorter that calls the comparator.
  • SortState: the result of BytesRefArray.sort(comp, stable), a permutation that iterator(SortState) replays.

The bug

  1. iterator(Comparator) (BytesRefArray.java:198-201) is return iterator(sort(comp, false)); (:200). Nothing checks for null.
  2. sort(comp, stable) (:128-180) fills a local int[] orderedEntries = new int[size()] (:129), wraps it in new StringSorter(comp) (:164; StableStringSorter at :136 when stable) and calls sorter.sort(0, size()) (:179).
  3. StringSorter.sort (StringSorter.java:52-57) radix-sorts only if (cmp instanceof BytesRefComparator bCmp); null is not an instance of anything, so it calls fallbackSorter(cmp).sort(from, to) (:56). The fallback IntroSorter's compare (:98-103) calls cmp.compare(scratchBytes1, scratchBytes2) (:102).
  4. For 2 values IntroSorter goes straight to insertion sort (IntroSorter.java:128), whose first step compares values 0 and 1 (Sorter.java:244): NullPointerException. With 0 or 1 values no pair is compared, so no exception.
  5. The append-order code is one overload away: iterator(SortState) (:209-211) uses indices = sortState == null ? null : sortState.indices, with the same null rule in its javadoc (:203-207), and iterator() calls it with (SortState) null (:185). iterator(Comparator) just never routes null there.
  6. Since 8.5 a bare array.iterator(null) does not compile: javac reports "reference to iterator is ambiguous ... both method iterator(Comparator<BytesRef>) in BytesRefArray and method iterator(SortState) in BytesRefArray match". A caller reaches the bug with a typed null: a cast, or a Comparator variable that is null when no order is wanted.

The numbers

Two values b, a: iterator((Comparator<BytesRef>) null) throws on main, 10.6.0, 10.5.2 and 9.12.3, with assertions on and off. The history probe (values c, a, b) prints cab on all 8 releases from 8.0.0 to 8.4.1 (and on 4.8.0 and 7.7.3) and throws on all 52 releases from 8.5.0 to 10.5.2. The no-arg iterator(), a null comparator on 0 or 1 values, and any non-null comparator work.

Exposure and likelihood415 words
  • Who is hit. Code that uses BytesRefArray directly and passes a null comparator to mean "append order", as the javadoc invites, on 2 or more values. For example (illustrative, not found in any project): a helper that takes an optional Comparator<BytesRef> order and calls array.iterator(order), or code written for 8.4 or earlier as array.iterator(null), which stopped compiling in 8.5 and was fixed with a (Comparator<BytesRef>) cast.
  • Also hit, indirectly. Code that builds an InMemorySorter (suggest module) or an OfflineSorter with a null comparator. Both store it without a check and pass it on to iterator(Comparator) (InMemorySorter.java:38-39, :51; OfflineSorter.java:219-221, :268, :637), so new InMemorySorter(null) and new OfflineSorter(dir, "tmp", null).sort(...) on b, a throw the same NullPointerException (executed on main, assertions on and off). Both classes are @lucene.internal and @lucene.experimental, and their constructors are documented as "sorting entries by the provided comparator" and "with a custom comparator", so a null there is outside their own documented use.
  • What happens. That one call fails at once with an internal NullPointerException from StringSorter, which points at the sorter, not at the argument.
  • What does not happen. No wrong results, no hang, no lasting resource use: before it throws, the call allocates a temporary int[size()] and a sorter (:129, :164), which become garbage. sort only permutes that local orderedEntries array, so the array's pool, offsets and size are untouched and it stays usable: after the exception on c, a, b, size() is still 3, iterator() gives cab, a naturalOrder() iterator gives abc, and a further append works (executed, assertions on and off). No index is written from this path.
  • No Lucene code supplies a null comparator. OfflineSorter passes its configured comparator (OfflineSorter.java:637), and every new OfflineSorter( in the tree gives it a non-null one (the default naturalOrder(), BytesRefComparator.NATURAL or a suggester comparator); InMemorySorter passes its constructor's comparator (InMemorySorter.java:51), and both in-tree constructions use Comparator.naturalOrder() (main source FSTCompletionBuilder.java:133, test TestBytesRefSorters.java:56); FieldUpdatesBuffer sorts with BytesRefComparator.NATURAL and iterates with iterator(SortState) or iterator() (FieldUpdatesBuffer.java:226, 234, 325-328). No test or main source in the tree calls iterator with a null comparator.
  • Default vs opt-in, scale. Opt-in: the application itself supplies the null comparator, to this internal utility or to one of the two internal sorters above. Any 2 values trigger it; data size plays no part. Likelihood: rare. No report of it since 8.5.0 (2020) was found (see Prior reports).

Minimal repro

One JUnit test on the public API: append b and a to a BytesRefArray and call iterator((Comparator<BytesRef>) null). It expects append order and fails only when the NullPointerException escapes. Deterministic, single thread, about 1.5 s; the same with assertions on and off.

TestBytesRefArrayIteratorNullComparator.java (JUnit, one test)39 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.util;

import java.io.IOException;
import java.util.Comparator;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBytesRefArrayIteratorNullComparator extends LuceneTestCase {

  public void testNullComparatorIteratesInAppendOrder() throws IOException {
    BytesRefArray array = new BytesRefArray(Counter.newCounter());
    array.append(new BytesRef("b"));
    array.append(new BytesRef("a"));
    BytesRefIterator it;
    try {
      it = array.iterator((Comparator<BytesRef>) null);
    } catch (NullPointerException e) {
      throw new AssertionError("iterator(null): expected append order b, a, got " + e, e);
    }
    assertEquals(new BytesRef("b"), it.next());
    assertEquals(new BytesRef("a"), it.next());
    assertNull(it.next());
  }
}
Command
cp TestBytesRefArrayIteratorNullComparator.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestBytesRefArrayIteratorNullComparator -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
b, then a, then null: the values in append order, as the javadoc says and as 8.4.1 and earlier do.
Observed
NullPointerException ("this.val$cmp" is null) at StringSorter$1.compare(StringSorter.java:102), thrown from BytesRefArray.iterator(BytesRefArray.java:200); the same with assertions off.

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

More files, environment, full output284 words

TestBytesRefArrayIteratorNullComparator creates new BytesRefArray(Counter.newCounter()), appends b and a, calls iterator((Comparator<BytesRef>) null) and asserts b, a, then null. A NullPointerException is rethrown as an AssertionError that states expected and actual, with the original as its cause. Two values are the smallest input that fails (0 or 1 values make no comparison), and b, a is not already sorted, so a pass shows append order rather than an accidental sort. Public API only (it sits in org.apache.lucene.util next to BytesRefArray, by Lucene convention); no randomness of its own, so the seed does not matter (seeds DEADBEEF and 5EED1234 gave the same failure). It compiles on 9.1 and later (it needs the org.apache.lucene.tests.util test framework).

As the regression test: this same test, for example as a new method in TestBytesRefArray. With fix A it passes, and ./gradlew :lucene:core:check -x test passes with it as a new test file. If the maintainer picks option B (reject null), its assertion becomes expectThrows(NullPointerException.class, () -> array.iterator((Comparator<BytesRef>) null)).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0, 10.5.2 and 9.12.3; every release from 8.5.0 to 10.6.0 is affected
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestBytesRefArrayIteratorNullComparator.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestBytesRefArrayIteratorNullComparator -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

TestBytesRefArrayIteratorNullComparator > testNullComparatorIteratesInAppendOrder FAILED
    java.lang.AssertionError: iterator(null): expected append order b, a, got java.lang.NullPointerException: Cannot invoke "java.util.Comparator.compare(Object, Object)" because "this.val$cmp" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:73D58901F5D8243]:0)
        at org.apache.lucene.util.TestBytesRefArrayIteratorNullComparator.testNullComparatorIteratesInAppendOrder(TestBytesRefArrayIteratorNullComparator.java:33)

        Caused by:
        java.lang.NullPointerException: Cannot invoke "java.util.Comparator.compare(Object, Object)" because "this.val$cmp" is null
            at org.apache.lucene.util.StringSorter$1.compare(StringSorter.java:102)
            at org.apache.lucene.util.Sorter.insertionSort(Sorter.java:244)
            at org.apache.lucene.util.IntroSorter.sort(IntroSorter.java:128)
            at org.apache.lucene.util.IntroSorter.sort(IntroSorter.java:44)
            at org.apache.lucene.util.StringSorter.sort(StringSorter.java:56)
            at org.apache.lucene.util.BytesRefArray.sort(BytesRefArray.java:179)
            at org.apache.lucene.util.BytesRefArray.iterator(BytesRefArray.java:200)
            at org.apache.lucene.util.TestBytesRefArrayIteratorNullComparator.testNullComparatorIteratesInAppendOrder(TestBytesRefArrayIteratorNullComparator.java:31)

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false; identical with assertions on

Why it looks unintended

  • Its own javadoc: a non-null comparator sorts, "Otherwise the order is the same as the values were appended"; iterator() is "sugar for iterator(Comparator) with a null comparator".
  • It worked from 2012 to 8.4.1; LUCENE-9228, a performance change, dropped the null check and left the javadoc unchanged.
  • Nothing says the exception is intended: no test, review comment or migration note.
The full argument241 words
  • Contract, this exact method. iterator(Comparator): "If a non null Comparator is provided the iterator will iterate the byte values in the order specified by the comparator. Otherwise the order is the same as the values were appended." (BytesRefArray.java:192-194). iterator(): "sugar for iterator(Comparator) with a null comparator" (:183). iterator(SortState) states the same null rule (:203-207). No @throws, nothing says null is rejected.
  • History: the null path worked for 8 years; then LUCENE-9228 changed it without updating the javadoc. f303bcd4650 (LUCENE-3807, 2012-03-02) wrote comp == null ? null : sort(comp) together with this javadoc (then BytesRefList in the suggest module; in core as BytesRefArray since 4.8.0). Until 8.4.1, iterator() was literally return iterator(null);, so every iterator() call used the null path. a0b8f5c7c28 (LUCENE-9228, "Sort dvUpdates by terms before apply", 2020-02-20) replaced the body with an unconditional sort, sent iterator() to the new iterator((SortState) null) and left both javadocs unchanged. After that no in-tree caller or test passed null, which is why nothing caught it.
  • No sign of intent. The review (apache/lucene-solr#1263), the issue (#10268) and the later rewrite #12591 do not mention null comparators; CHANGES.txt and MIGRATE.md say nothing about it. That shows nobody documented or discussed a change to the null contract; it does not prove the change was an accident.
  • Tests. No upstream test passes a null comparator; TestBytesRefArray uses iterator(), a non-null comparator (TestBytesRefArray.java:93-95) and iterator(SortState).
The devil's-advocate check205 words

The strongest case for "not a bug" (the intended-behaviour checker's and the cross-model refute reviewer's): BytesRefArray is @lucene.internal and @lucene.experimental; the package-private interface it implements only says "Sort all values by the provided comparator and return an iterator over the sorted values" (SortableBytesRefArray.java:32-33); the sibling FixedLengthBytesRefArray.iterator documents no null case (FixedLengthBytesRefArray.java:153-162) and also sorts unconditionally (:166), so a null comparator throws the same NullPointerException there on 2 values (executed); and no Lucene code supplies null. So the null sentence may be a stale leftover and this a documentation defect.

Why it fails: the public javadoc of this exact method, and of iterator(), state the null behaviour and its result; the behaviour was implemented and exercised from 2012 to 8.4.1; it changed in a performance commit that updated neither javadoc and recorded no contract change; and @lucene.internal means the API may change between releases, not that a method may contradict its current javadoc. The sibling's behaviour supports the stale-leftover reading, but it does not change what this method's own javadoc promises. Residual: a maintainer may still prefer to drop the null case and fix the javadoc (option B). That is a choice of fix, not "works as designed".

Suggested fix

Recommended A. Restore the null check in iterator(Comparator) (append order for null)

Restore the null check that 8.4.1 had: return iterator(comp == null ? null : sort(comp, false)); in BytesRefArray.iterator(Comparator). A null comparator then gives append order again, as documented. With it the repro passes with assertions on and off, and the closest core suites, the FST suggester tests and core's static checks pass.

--- a/lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java
+++ b/lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java
@@ -197,7 +197,7 @@
    */
   @Override
   public BytesRefIterator iterator(final Comparator<BytesRef> comp) {
-    return iterator(sort(comp, false));
+    return iterator(comp == null ? null : sort(comp, false));
   }
 
   /**
All options and trade-offs2 options · 480 words

Fix A was validated through gradle in a scratch worktree of 645d4fe14a20. Option B is the alternative the adjudication named; it was not run.

A. Restore the null check in iterator(Comparator) (append order for null) (validated by the checks) (recommended)

Skip the sort when the comparator is null and pass a null SortState, which iterator(SortState) already turns into append order. This is the pre-8.5 behaviour in today's code.

--- a/lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java
+++ b/lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java
@@ -197,7 +197,7 @@
    */
   @Override
   public BytesRefIterator iterator(final Comparator<BytesRef> comp) {
-    return iterator(sort(comp, false));
+    return iterator(comp == null ? null : sort(comp, false));
   }
 
   /**

Trade-offs

  • Restores the documented behaviour of 2012 to 8.4.1 and makes iterator() truly "sugar for iterator(Comparator) with a null comparator" again; no javadoc change needed.
  • No change for any non-null comparator, which is every in-tree caller.
  • Cost: one null check per call; a null call also skips the int[size()] allocation and the sort.
  • It keeps a null-means-unsorted convention that the sibling FixedLengthBytesRefArray and the package-private SortableBytesRefArray interface do not have.
  • Coverage: the minimal test checks 2 values; the fix covers every size by construction, because a null comparator skips sort and goes to iterator((SortState) null), which iterates in append order (BytesRefArray.java:209-211, code reading).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off.
  • Closest core suites with the fix: TestBytesRefArray, TestFixedLengthBytesRefArray, TestStringSorter, TestOfflineSorter, TestFieldUpdatesBuffer and the repro: 6 classes, 35 tests, 0 failures, 2 skipped (nightly).
  • ./gradlew :lucene:suggest:test --tests 'org.apache.lucene.search.suggest.fst.*' (InMemorySorter calls iterator(Comparator)): 3 classes, 24 tests, 0 failures, 1 skipped (nightly).
  • ./gradlew :lucene:core:check -x test with the fix and the repro as a new test file: passes (google-java-format, ecj lint, forbidden APIs; error-prone skipped). The full Lucene test suite was not run.
  • Earlier, with the fix shadowed into the stock jars: the finder's repro, the upstream TestBytesRefArray and the independent reproducer's 3 tests pass; the minimal repro passes with assertions on and off.

B. Drop the null case and correct the javadoc

Reword iterator(Comparator) to say the comparator must not be null and point append-order callers to iterator(); reword iterator() to describe append order directly; optionally add Objects.requireNonNull(comp, "comp") at the top of iterator(Comparator).

Trade-offs

  • Matches what 8.5.0 to 10.6.0 already do with 2 or more values, and the comparator-only wording of SortableBytesRefArray and FixedLengthBytesRefArray (neither says null is allowed or rejected; FixedLengthBytesRefArray throws the same NullPointerException for it).
  • Removes a documented feature (of an internal API, so allowed between releases) instead of restoring it.
  • With requireNonNull, a null call on 0 or 1 values, which works today, would start to throw; without it, the failure stays an obscure NPE inside StringSorter.
  • The regression test becomes an expectThrows(NullPointerException.class, ...).

Downstream note

A code search found no null-comparator call of BytesRefArray.iterator(Comparator) in Solr, Elasticsearch or OpenSearch; applications that pass null to get append order can call the no-arg iterator() instead, which works on every version.

How it was found

Non-formal: code reading. While an AI tool wrote a reference model of BytesRefArray from its javadoc (the model itself only tried non-null comparators), it noticed that iterator(Comparator) sorts even when the comparator is null. A public-API JUnit test on the stock jars then showed the exception.

The method in full142 words
  • Method: code reading, the hunt's non-formal tier, in the hunt unit on indexing-time pools (BytesRefHash, byte pools, BytesRefArray). The unit wrote a javadoc-derived reference model for BytesRefArray and compared it with the real class. That differential only exercised non-null comparators, so it did not find this; reading iterator(Comparator) while writing the model's null case showed the unconditional sort.
  • Witness: a JUnit test on the stock main jar (three values c, a, b, plus a no-arg iterator() control) failed with the NPE while the control passed; a shadowed one-line fix made it pass.
  • Later: an independent reproducer, an intended-behaviour devil's advocate, cross-model reviews and an adjudication checked it (2026-10-26); the history was bisected by execution over 60 releases; the test was reduced to one test method on two values (2026-10-29) and re-verified on main 645d4fe14a20.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the JIRA archive, the Lucene mailing lists, downstream projects or the web (searched 2026-10-26, re-checked 2026-11-01). The closest item is LUCENE-9228 (#10268), the change that introduced it. Not fixed upstream as of 2026-10-30.

Searches4 searches

Closest items, none a report of this bug: #10268 / LUCENE-9228 and its review apache/lucene-solr#1263 (the introducing change; no mention of null comparators), #12591 (moved the sort onto StringSorter and kept the unconditional sort), #12587 (radix-sorts TermInSetQuery terms with StringSorter), #4880 / LUCENE-3807 (2012, where the null contract was written), #13948 (the only open PR touching BytesRefArray.java; it adds an append overload and does not touch iterator). Gaps: a search shows only that no report was found; GitHub search reads the first page of each query (the specific queries had fewer results than that); private channels were not searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene and apache/lucene-solr issues, PRs and commits2026-10-26two scripted prior-report searches (16 and 24 issue/PR queries, 3 commit queries); 14 direct searches (BytesRefArray, StringSorter, "null comparator", the javadoc phrase "the same as the values were appended", SortState, the NPE tokens "StringSorter$1" and "val$cmp"); the changed files of all 410 open PRs; the introducing issue, its review and #12591 read in fullno report; #10268 (LUCENE-9228) introduced the defect
Mailing lists, JIRA archive, downstream, web2026-10-2620 list queries over dev@, java-user@, issues@ and commits@ plus 5 direct queries per list; 4 scripted and 5 direct JIRA queries; Elasticsearch, OpenSearch and Solr issues and code; 13 web queriesno report
Re-check2026-11-01read-only GitHub issue and PR searches on apache/lucene for BytesRefArray, "null comparator" and StringSorter, sorted by creation date (issues/PRs: 13/5, 2/0 and 0/4 hits)nothing new (newest BytesRefArray hits: PR #16286, 2026-06, and issue #14261, 2025-02, both unrelated and already in the 2026-10-26 results)
Upstream main and branches2026-11-01file contents at main 2f424fd428da (2026-10-30), branch_11x, branch_10_6 and the 10.6.0 tagnot fixed; line 200 unchanged

History

Introduced in 8.5.0 by LUCENE-9228 (a0b8f5c7c28, 2020-02-20), which removed the null check; 8.4.1 and earlier iterate in append order. A probe on 60 releases from 8.0.0 to 10.5.2 puts the boundary exactly at 8.4.1 to 8.5.0; 10.6.0 and main also fail.

Versions tested7 rows

Introduced by LUCENE-9228 (apache/lucene#10268, "Sort dvUpdates by terms before apply", review apache/lucene-solr#1263, committed directly by GitHub dnhatn; master a0b8f5c7c28, branch_8x backport 83ccb8d2a22, both 2020-02-20), first released in 8.5.0 (on the main line, 9.0.0). The null contract dates from f303bcd4650 (LUCENE-3807, 2012). 3cb71436a7b (#12591, GitHub gf2121, 2023-09-29; first in 9.9.0) rewrote the body to today's return iterator(sort(comp, false)); through StringSorter and kept the defect: the NPE frame moved from BytesRefArray$1.compare ("this.val$comp" is null) to StringSorter$1.compare ("this.val$cmp" is null) exactly between 9.8.0 and 9.9.0. The 8.8.2-8.11.4 tags are not in the history clone, so for those releases "affected" comes from execution only.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and gradle); passes with fix A
10.6.0, 10.5.2, 9.12.3 affectedminimal repro fails (10.6.0 and 10.5.2 with assertions on, 9.12.3 with assertions off); StringSorter.java:103 on 9.12.3
10.0.0, 9.9.0, 9.8.0 affectedthe finder's 3-value test fails; on 9.8.0 the frame is BytesRefArray$1.compare
8.5.0 to 10.5.2 (52 releases) first affected 8.5.0the main() probe (values c, a, b) throws NullPointerException on every one
8.0.0 to 8.4.1 (8 releases), 7.7.3, 4.8.0 last unaffected 8.4.1the probe prints append order cab
a0b8f5c7c28 / 83ccb8d2a22 (LUCENE-9228) introducing commitremoved comp == null ? null : sort(comp) and moved iterator() to iterator((SortState) null) (git show)
upstream main 2f424fd428da, branch_11x, branch_10_6 readbyte-identical BytesRefArray.java, unconditional sort at line 200 (source read, not executed)

Independent checks

Confirmed by an independent reproducer, an intended-behaviour devil's advocate, a reviewer from another model family (OpenAI GPT-5.6) and an adjudication (high confidence). A second cross-model review said not real only because it could not run code, while agreeing on the root cause. This doc's gradle run validated the fix.

Checks and verdicts282 words

Seven checks; none disputed the root cause.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realIts own 3-test JUnit fails 2 of 3 on main with assertions on and off, on 10.5.2 and on 9.12.3, while its control (0/1 values, no-arg iterator, non-null comparator) passes; its main() probe passes on 8.4.1 and throws on 8.5.0; a shadowed one-line fix makes all 3 pass.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real, medium confidenceAgreed with the contract reading and the root cause; a separate re-run of the repro failed and the fix check passed.
intended-behaviour devil's advocate real, low severityBuilt the "stale javadoc" case (internal class, interface and sibling class promise no null, no in-tree caller) and rejected it: this method's own javadoc allows null, and the change came with no javadoc update or contract note.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real, medium confidenceRested on "could not reproduce" (it could not run code; a separate run of the repro failed) and the internal scope, while calling it "a genuine code-versus-javadoc inconsistency".
adjudication confirmed, high confidence, minorRe-ran the repro (fails) and the fix check (passes), read the introducing diff, and named a javadoc correction as the alternative fix.
audit and main refresh confirmed, low priorityStill reproduces on main a9779ef3c193; confirmed as a behaviour regression from a documented contract that once worked, borderline with a documentation defect.
brief validation (gradle) fix A validatedRepro fails on stock main with assertions on and off and passes with fix A; the closest core suites, the FST suggester tests and core's static checks pass.

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

Finding record

LFV-392, snapshot of 2026-11-06

Registry title
BytesRefArray.iterator(Comparator) throws NullPointerException for a null comparator once the array holds 2 or more values, instead of iterating in append order as its javadoc says (regression since 8.5.0, LUCENE-9228)
Area
util / BytesRefArray (iterator(Comparator) with a null comparator)
Priority
Low, rank 1132: The internal BytesRefArray API throws NullPointerException at once, and only for an explicit typed null comparator that no known caller passes, so it is a contract-only edge case.
Impact class
minor
Impact note
Only code that supplies a null comparator on an array of 2 or more values is hit, directly to the @lucene.internal/@lucene.experimental BytesRefArray.iterator(Comparator) or to an InMemorySorter or OfflineSorter built with a null comparator, which pass it on (executed on main 645d4fe14a20, 2026-11-01), and it gets an immediate NullPointerException (never wrong results, a hang or an index change); no Lucene caller passes null (OfflineSorter and InMemorySorter pass their configured comparator, FieldUpdatesBuffer and the no-arg iterator() use iterator(SortState)), so default indexing, search, merging, doc-values updates and suggester builds never reach it, no use was found in Solr, Elasticsearch or OpenSearch, and every release from 8.5.0 to 10.6.0 is affected.
Summary
You append two or more values (for example c, a, b) to a BytesRefArray and call iterator((Comparator<BytesRef>) null), expecting the values back in append order, as the method's javadoc promises ('Otherwise the order is the same as the values were appended') and as Lucene 8.4.1 and earlier do. Instead the call throws NullPointerException ('Cannot invoke Comparator.compare ... because this.val$cmp is null', from StringSorter$1.compare via BytesRefArray.sort), because the method now always sorts with the given comparator; with 0 or 1 values, or with the no-arg iterator(), append order still comes back. The one-line fix restores the pre-8.5 null check ('return iterator(comp == null ? null : sort(comp, false));'); a maintainer may instead prefer to correct the javadoc.
Introduced
a0b8f5c7c28bd897bdf48ed7af20b385a236b3a9 'LUCENE-9228: Sort dvUpdates by terms before apply' (by GitHub dnhatn; issue apache/lucene#10268; reviewed in apache/lucene-solr#1263 and committed directly; branch_8x backport 83ccb8d2a2204c78de0cf119d63ef452ef95fcc6, same diff, first released in 8.5.0): it removed 'final int[] indices = comp == null ? null : sort(comp);', made iterator(Comparator) sort unconditionally, sent iterator() to the new iterator((SortState) null) overload and left the iterator(Comparator) javadoc unchanged [read: git log -S and git show in the full-history clone; exec: 8.4.1 passes, 8.5.0 fails]. 3cb71436a7b (apache/lucene#12591, 9.x backport 7c1d1147beb, first in 9.9.0) rewrote the body to 'return iterator(sort(comp, false));' through StringSorter and kept the defect (the NPE frame moved from BytesRefArray$1.compare to StringSorter$1.compare, executed at 9.8.0 -> 9.9.0). The null contract itself dates from f303bcd4650 (LUCENE-3807, 2012) (2020-02-20 (a0b8f5c7c28 and the branch_8x backport 83ccb8d2a22))
First affected version
8.5.0 [exec] (8.0.0-8.4.1 iterate in append order [exec]; the first main-line release with the change is 9.0.0)
Affected versions
Every release from 8.5.0 through 10.6.0 and main ea57360912ed (10.6.0: the one-test minimal repro fails on the release jars, SHA-1 checked against Maven Central, -ea, 2026-11-01 [exec]); not fixed upstream (apache/lucene main cf398bfb142c, branch_10x and branch_10_5 still have 'return iterator(sort(comp, false));', read-only check 2026-10-26). [exec] a version sweep with a main() probe (values c, a, b appended, JDK 25, release jars SHA-1 checked): of 60 releases from 8.0.0 to 10.5.2, the 8 releases 8.0.0-8.4.1 print append order 'cab' and the 52 releases 8.5.0-8.11.4, 9.0.0-9.12.3 and 10.0.0-10.5.2 throw NullPointerException; 4.8.0 and 7.7.3 also pass. The finder's JUnit fails on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3, 9.9.0 and 9.8.0; the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3, and the independent reproduction's main() probe passes on 8.4.1 and fails on 8.5.0. [read] the 8.8.2-8.11.4 tags are not in the history clone, so for those releases the status comes from execution only. Arrays with 0 or 1 values are unaffected.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code and voted “not real” while agreeing with the root cause (the adjudication ruled it real), 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 (searched 2026-10-26, read-only: two scripted prior-report searches (16 and 24 GitHub issue and PR queries, 3 commit, 2 and 3 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA and 6 web queries), 14 direct GitHub searches over apache/lucene, apache/lucene-solr, Solr, Elasticsearch and OpenSearch, a GitHub code search for 'iterator(null)' BytesRefArray (only vendored copies of the pre-8.5 class), the changed files of all 410 open apache/lucene PRs, 5 JIRA queries, 5 direct list-archive queries per list and 7 web searches; the introducing issue #10268 / LUCENE-9228, its review apache/lucene-solr#1263 and the later #12591 read in full); 26 hits assessed, none the same bug. Nearest: #10268 / LUCENE-9228 and apache/lucene-solr#1263 (the introducing change; no mention of null comparators), #12591 (moved BytesRefArray.sort onto StringSorter and kept the unconditional sort), #4880 / LUCENE-3807 (2012, where the null contract was written), #13948 (the only open PR touching BytesRefArray.java; it does not touch iterator(Comparator)). Not fixed upstream (main cf398bfb142c, branch_10x, branch_10_5).
Lucene commit tested
main 645d4fe14a20: the minimal repro with assertions on and off, standalone and through gradle; the recommended fix in a scratch worktree with the closest core suites, the suggest module's FST suggesters and :lucene:core:check -x test. SHA-1-checked release jars: the minimal repro on 10.6.0, 10.5.2 and 9.12.3; the finder's 3-value test on 9.8.0, 9.9.0 and 10.0.0; a main() probe on 60 releases from 8.0.0 to 10.5.2, plus 4.8.0 and 7.7.3. BytesRefArray.java and StringSorter.java are byte-identical from the study base 7cecf795236c through 645d4fe14a20 to upstream main 2f424fd428da (2026-10-30) and the 10.6.0 tag (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java:29-30 (@lucene.internal, @lucene.experimental), :128-180 (sort: StringSorter over a local orderedEntries array), :183-186 (iterator() 'sugar for iterator(Comparator) with a null comparator', body iterator((SortState) null)), :188-197 (iterator(Comparator) javadoc; contract text at :192-194: 'If a non null Comparator is provided ... Otherwise the order is the same as the values were appended.'), :198-201 (body at :200 'return iterator(sort(comp, false));'), :209 (iterator(SortState) handles null)
  • lucene/core/src/java/org/apache/lucene/util/StringSorter.java:52-57 (radix sort only for a BytesRefComparator, else the comparison fallback), :98-103 (fallback compare calls cmp.compare)
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:637 and lucene/suggest/src/java/org/apache/lucene/search/suggest/InMemorySorter.java:51 (the in-tree callers of iterator(Comparator), both passing a configured comparator)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java:29-30, :128-180, :183-186, :188-201, :203-211 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/StringSorter.java:52-57, :98-103; IntroSorter.java:40-44, :128; Sorter.java:240-244 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:215-221, :268, :637; lucene/suggest/src/java/org/apache/lucene/search/suggest/InMemorySorter.java:37-39, :51; lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionBuilder.java:133; lucene/suggest/src/test/org/apache/lucene/search/suggest/fst/TestBytesRefSorters.java:56; lucene/core/src/java/org/apache/lucene/index/FieldUpdatesBuffer.java:226, :234, :325-328 (at 645d4fe14a20)
  • grep for 'new OfflineSorter(' and 'new InMemorySorter(' over lucene/ at 645d4fe14a20 (every construction passes a non-null comparator)
  • grep for 'iterator(null' and 'iterator((Comparator' over lucene/ at 645d4fe14a20 (no hits)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java:183, :192-194, :203-207; SortableBytesRefArray.java:32-33; FixedLengthBytesRefArray.java:122-150, :153-166 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/TestBytesRefArray.java:64, :93-95, :136 (at 645d4fe14a20)
History
  • a Lucene clone: git rev-parse <ref>:lucene/core/src/java/org/apache/lucene/util/BytesRefArray.java for 645d4fe14a20, origin/main 2f424fd428da, releases/lucene/10.6.0 (blob dd8473a408b5)

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 mechanism in the brief; the PNG is the summary frame.

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.