BytesRefArrayis an internal, append-only list of byte strings (BytesRef). Lucene uses it as an in-memory buffer, for example inOfflineSorterand when buffering doc-values updates.- 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. - Sorting goes through
StringSorter: a radix sort for aBytesRefComparator, otherwise a comparison sort that calls the comparator on pairs of values.
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.- Append two values to a
BytesRefArray,bthena, and callarray.iterator((Comparator<BytesRef>) null). The cast is needed: since 8.5 a bareiterator(null)does not compile, because two overloads match. - You expect
b, thena. 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. - Instead the call throws
NullPointerException: Cannot invoke "java.util.Comparator.compare(Object, Object)" because "this.val$cmp" is null, fromStringSorter, with assertions on and off. - Why: the body is
return iterator(sort(comp, false));(BytesRefArray.java:200), a sort even for null. A null comparator falls toStringSorter's comparison sort, which callscmp.compareon the first pair (StringSorter.java:102). With 0 or 1 values nothing is compared, so it takes 2. - 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-argiterator()is not 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.
BytesRefArray(org.apache.lucene.util, core): an append-only list ofBytesRefvalues stored in aByteBlockPool. Marked@lucene.internaland@lucene.experimental(BytesRefArray.java:29-30).StringSorter(org.apache.lucene.util, core): the sorterBytesRefArray.sortuses since 9.9.0 (#12591). It radix-sorts when the comparator is aBytesRefComparatorand otherwise falls back to anIntroSorterthat calls the comparator.SortState: the result ofBytesRefArray.sort(comp, stable), a permutation thatiterator(SortState)replays.
iterator(Comparator)(BytesRefArray.java:198-201) isreturn iterator(sort(comp, false));(:200). Nothing checks for null.sort(comp, stable)(:128-180) fills a localint[] orderedEntries = new int[size()](:129), wraps it innew StringSorter(comp)(:164;StableStringSorterat:136whenstable) and callssorter.sort(0, size())(:179).StringSorter.sort(StringSorter.java:52-57) radix-sorts onlyif (cmp instanceof BytesRefComparator bCmp);nullis not an instance of anything, so it callsfallbackSorter(cmp).sort(from, to)(:56). The fallbackIntroSorter'scompare(:98-103) callscmp.compare(scratchBytes1, scratchBytes2)(:102).- For 2 values
IntroSortergoes 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. - The append-order code is one overload away:
iterator(SortState)(:209-211) usesindices = sortState == null ? null : sortState.indices, with the same null rule in its javadoc (:203-207), anditerator()calls it with(SortState) null(:185).iterator(Comparator)just never routes null there. - 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 aComparatorvariable that is null when no order is wanted.
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
BytesRefArraydirectly 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 optionalComparator<BytesRef> orderand callsarray.iterator(order), or code written for 8.4 or earlier asarray.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 anOfflineSorterwith a null comparator. Both store it without a check and pass it on toiterator(Comparator)(InMemorySorter.java:38-39, :51;OfflineSorter.java:219-221, :268, :637), sonew InMemorySorter(null)andnew OfflineSorter(dir, "tmp", null).sort(...)onb,athrow the sameNullPointerException(executed on main, assertions on and off). Both classes are@lucene.internaland@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
NullPointerExceptionfromStringSorter, 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.sortonly permutes that localorderedEntriesarray, so the array's pool, offsets and size are untouched and it stays usable: after the exception onc,a,b,size()is still 3,iterator()givescab, anaturalOrder()iterator givesabc, and a furtherappendworks (executed, assertions on and off). No index is written from this path. - No Lucene code supplies a null comparator.
OfflineSorterpasses its configured comparator (OfflineSorter.java:637), and everynew OfflineSorter(in the tree gives it a non-null one (the defaultnaturalOrder(),BytesRefComparator.NATURALor a suggester comparator);InMemorySorterpasses its constructor's comparator (InMemorySorter.java:51), and both in-tree constructions useComparator.naturalOrder()(main sourceFSTCompletionBuilder.java:133, testTestBytesRefSorters.java:56);FieldUpdatesBuffersorts withBytesRefComparator.NATURALand iterates withiterator(SortState)oriterator()(FieldUpdatesBuffer.java:226, 234, 325-328). No test or main source in the tree callsiteratorwith 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).
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.
/*
* 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());
}
}
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, thena, thennull: the values in append order, as the javadoc says and as 8.4.1 and earlier do.- Observed
NullPointerException("this.val$cmp" is null) atStringSorter$1.compare(StringSorter.java:102), thrown fromBytesRefArray.iterator(BytesRefArray.java:200); the same with assertions off.
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)).
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
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- 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 nonnullComparator 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 foriterator(Comparator)with anullcomparator" (: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) wrotecomp == null ? null : sort(comp)together with this javadoc (thenBytesRefListin the suggest module; in core asBytesRefArraysince 4.8.0). Until 8.4.1,iterator()was literallyreturn iterator(null);, so everyiterator()call used the null path.a0b8f5c7c28(LUCENE-9228, "Sort dvUpdates by terms before apply", 2020-02-20) replaced the body with an unconditional sort, sentiterator()to the newiterator((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;
TestBytesRefArrayusesiterator(), a non-null comparator (TestBytesRefArray.java:93-95) anditerator(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".
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 foriterator(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
FixedLengthBytesRefArrayand the package-privateSortableBytesRefArrayinterface do not have. - Coverage: the minimal test checks 2 values; the fix covers every size by construction, because a null comparator skips
sortand goes toiterator((SortState) null), which iterates in append order (BytesRefArray.java:209-211, code reading).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF, 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,TestFieldUpdatesBufferand the repro: 6 classes, 35 tests, 0 failures, 2 skipped (nightly). ./gradlew :lucene:suggest:test --tests 'org.apache.lucene.search.suggest.fst.*'(InMemorySortercallsiterator(Comparator)): 3 classes, 24 tests, 0 failures, 1 skipped (nightly)../gradlew :lucene:core:check -x testwith 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
TestBytesRefArrayand the independent reproducer's 3 tests pass; the minimal repro passes with assertions on and off.
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
SortableBytesRefArrayandFixedLengthBytesRefArray(neither says null is allowed or rejected;FixedLengthBytesRefArraythrows the sameNullPointerExceptionfor 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 insideStringSorter. - The regression test becomes an
expectThrows(NullPointerException.class, ...).
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.
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 forBytesRefArrayand compared it with the real class. That differential only exercised non-null comparators, so it did not find this; readingiterator(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-argiterator()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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene and apache/lucene-solr issues, PRs and commits | 2026-10-26 | two 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 full | no report; #10268 (LUCENE-9228) introduced the defect |
| Mailing lists, JIRA archive, downstream, web | 2026-10-26 | 20 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 queries | no report |
| Re-check | 2026-11-01 | read-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 branches | 2026-11-01 | file contents at main 2f424fd428da (2026-10-30), branch_11x, branch_10_6 and the 10.6.0 tag | not fixed; line 200 unchanged |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle); passes with fix A |
| 10.6.0, 10.5.2, 9.12.3 | affected | minimal 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 | affected | the 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.0 | the 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.1 | the probe prints append order cab |
a0b8f5c7c28 / 83ccb8d2a22 (LUCENE-9228) | introducing commit | removed comp == null ? null : sort(comp) and moved iterator() to iterator((SortState) null) (git show) |
upstream main 2f424fd428da, branch_11x, branch_10_6 | read | byte-identical BytesRefArray.java, unconditional sort at line 200 (source read, not executed) |
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.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Its 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 confidence | Agreed 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 severity | Built 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 confidence | Rested 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, minor | Re-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 priority | Still 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 validated | Repro 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. |
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 421 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,208 words
- 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; amain()probe on 60 releases from 8.0.0 to 10.5.2, plus 4.8.0 and 7.7.3.BytesRefArray.javaandStringSorter.javaare byte-identical from the study base7cecf795236cthrough645d4fe14a20to upstream main2f424fd428da(2026-10-30) and the 10.6.0 tag (read).
- 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)
- TestBytesRefArrayIteratorNullComparator.java (JUnit, one test):
repro/LFV-392/src/org/apache/lucene/util/TestBytesRefArrayIteratorNullComparator.java(39 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-392/bug.png(421 KB)
