ToParentBlockJoinSortField sorts parents by a field of their children: the lowest or the highest child value decides. Since 10.5.0, childMissingValue says where a child without the field ranks in that choice. For strings, the join code marks "a missing child, sorted last, won" with a fake ordinal, and the comparator then looks it up.
- A block is a group of child documents followed by their parent, added together with
addDocuments.ToParentBlockJoinQuerymatches children and returns their parents. ToParentBlockJoinSortFieldsorts those parents by a child field:reverseChildren = falsetakes the lowest child value,truethe highest. Since 10.5.0,childMissingValue(STRING_FIRSTorSTRING_LASTfor strings) lets children without the field take part in that choice.- A string sort works on ordinals: each distinct value of the field in a segment has a number from 0 to
getValueCount() - 1. When a hit enters the top hits, the comparator copies its value withlookupOrd(ordinal).
- You index one block: a child with
s = "b", a child withouts, and their parent. You sort the parents withToParentBlockJoinSortField("s", STRING, false, true, null, SortField.STRING_LAST, parents, children): the highest child value decides (reverseChildren = true), and a child withoutscounts as "missing, sorts last". - You expect 1 hit, the parent. The missing child beats
bin the max, so the parent's sort key is "after every value". - You get
IndexOutOfBoundsExceptionfrom the doc-values terms dictionary, with assertions on and off. The 7-argument constructor withreverse = truefails the same way. - Why: the join wrapper reports "a missing child won" as ordinal 2,147,483,647 (
Integer.MAX_VALUE). The string comparator treats every ordinal except -1 as a real term and asks for term number 2,147,483,647 of a field that has 1 term. - Only
Type.STRINGwith max-child selection and childSTRING_LASTfails, on 10.5.0 to 10.6.0 and main. MIN selection,STRING_FIRST, anullchild missing value and parents whose children all lack the field do not throw.
Only applications on 10.5.0 or later that combine a STRING field, max-child selection and childMissingValue = STRING_LAST. For them ordinary data triggers it: a parent with one child that has the field and one that does not. Each search that puts such a parent in its top hits fails; nothing is written or corrupted.
Mechanism, terms and numbers510 words
Since 10.5.0 (#15727), ToParentBlockJoinSortField takes a childMissingValue: what a child without the sort field counts as in the min/max choice among its siblings (STRING_FIRST or STRING_LAST for Type.STRING). For strings the join wrapper ToParentDocValues.SortedDVs represents "a missing child, sorted last" by the ordinal Integer.MAX_VALUE, and with max-child selection (reverseChildren = true, or reverse = true on the 7-argument constructor) that marker wins for every parent whose block has a child with a value and a member without one. TermOrdValComparator.copy() treats only -1 as missing and calls lookupOrd(Integer.MAX_VALUE), and the default doc-values format rejects the ordinal with an IndexOutOfBoundsException. One block of three documents is enough; it fails with assertions on and off on main, 10.5.0, 10.5.2 and 10.6.0 (10.5.1 was run with assertions on only). Nothing is written or corrupted. A one-wrapper fix in the sort field stops the crash and passes the join module's tests; where such a parent should then sort, relative to parents with no valued child, is a design choice for the maintainers.
- Which comparator.
ToParentBlockJoinSortField.getStringComparator(ToParentBlockJoinSortField.java:214-232) buildsnew TermOrdValComparator(numHits, field, missingValue == STRING_LAST, getReverse(), Pruning.NONE)(:215-216, wheremissingValueis the parent missing value), selectsBlockJoinSelector.Type.MAXwhenreverseChildren(:221-222) and wraps the doc values withsortMissingLast = (childMissingValue == STRING_LAST)(:228-229). The 7-argument constructor passesreverseas bothreverseParentsandreverseChildren(:120-128). - The marker.
BlockJoinSelector.wrap(BlockJoinSelector.java:96-132,@lucene.internalat:34) reachesToParentDocValues.SortedDVs, which setsmissingOrd = sortMissingLast ? Integer.MAX_VALUE : -1(ToParentDocValues.java:55). Per parent it keeps the min or max ordinal of the children with a value (:65-78) and records whether the block has more members than valued children (:264-267,:310-311,:333).ordValue()returnsMath.min(ord, missingOrd)orMath.max(ord, missingOrd)(:96-104); for MAX with missing-last that isInteger.MAX_VALUE.lookupOrdpasses any ordinal straight through (:106-109). - Executed on the repro's index: at the parent, MAX + missing-last gives
ordValue() = 2147483647withgetValueCount() = 1; MIN + missing-first gives-1; the other two combinations give the real ordinal0. - The throw.
TermOrdValComparator.copy()(TermOrdValComparator.java:343-358) maps only-1to its missing state (:345-348), assertsord >= 0(:350) and callstermsIndex.lookupOrd(ord)(:354).Lucene90DocValuesProducerchecks the ordinal inTermsDict.seekExactand throwsIndexOutOfBoundsException(:1812-1814); a plainthrow, so-dafails too. - When.
TopFieldCollectorcallscopy()for every hit that enters the top-N queue (TopFieldCollector.java:126,:116); comparisons against the queue bottom use the ordinal without a lookup (TermOrdValComparator.java:324-341). A search fails once an affected parent is copied into its top hits; an affected parent that is never competitive is never copied, so the failure can look data- and top-N-dependent. - Not affected: MIN selection (
Math.minkeeps the real ordinal),STRING_FIRSTwith MAX (Math.max(ord, -1)), anullchild missing value, and parents whose children all lack the field (advanceExactreturns false,ToParentDocValues.java:316-328).
Exposure and likelihood202 words
- Who is hit.
ToParentBlockJoinSortFieldonType.STRINGwithreverseChildren = true(orreverse = trueon the 7-argument constructor) andchildMissingValue = SortField.STRING_LAST, on 10.5.0 or later. Default codec and settings; theSimpleTextformat also rejects the ordinal. By code reading the index size does not matter; the runs used small indexes. - Trigger. A parent block with at least one child that has the field and at least one member without it, which is the data the option exists for. A block member outside
childFilteralso counts as missing in this code (a separate finding, LFV-220), so a block whose filtered children all have the field but which holds another document type also throws. - What goes wrong. The search throws an unchecked
IndexOutOfBoundsExceptioninstead of returning results. Nothing is written or corrupted, and the same searcher keeps answering other queries and other sorts. - Reach. No downstream user of this combination is known (see the downstream note).
- Workaround.
childMissingValue = nullorSTRING_FIRST, or MIN selection, avoid the crash but give a different order. - How likely. Plausible: only users of a new option combination, but for them the first query that copies an affected parent into its top hits fails, every time.
One JUnit test in the join module's test package (public Lucene APIs plus test-framework helpers): one block of three documents, one sorted search, default codec, one thread, deterministic. It fails with assertions on and off on main, 10.5.0, 10.5.2 and 10.6.0 (10.5.1: assertions on only) and passes with the fix, so it doubles as the regression test.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search.join;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BytesRef;
public class TestBlockJoinSortStringLastReverse extends LuceneTestCase {
private static Document doc(String type, String s) {
Document d = new Document();
d.add(new StringField("type", type, Field.Store.NO));
if (s != null) {
d.add(new SortedDocValuesField("s", new BytesRef(s)));
}
return d;
}
public void testStringLastWithReverseChildren() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
w.addDocuments(List.of(doc("child", "b"), doc("child", null), doc("parent", null)));
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
BitSetProducer parents = new QueryBitSetProducer(new TermQuery(new Term("type", "parent")));
Query children = new TermQuery(new Term("type", "child"));
SortField sf =
new ToParentBlockJoinSortField(
"s",
SortField.Type.STRING,
false,
true,
null,
SortField.STRING_LAST,
parents,
new QueryBitSetProducer(children));
Query q = new ToParentBlockJoinQuery(children, parents, ScoreMode.None);
try {
assertEquals(1, new IndexSearcher(reader).search(q, 10, new Sort(sf)).scoreDocs.length);
} catch (IndexOutOfBoundsException e) {
// expected: the parent is returned; actual: lookupOrd(Integer.MAX_VALUE) throws
throw new AssertionError("expected 1 sorted hit, got " + e, e);
}
}
}
}
}
cp $REPRO/TestBlockJoinSortStringLastReverse.java $LUCENE/lucene/join/src/test/org/apache/lucene/search/join/
cd $LUCENE && ./gradlew :lucene:join:test --tests org.apache.lucene.search.join.TestBlockJoinSortStringLastReverse -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways
- Expected
search(q, 10, new Sort(sf))returns 1 hit, the parent.- Observed
IndexOutOfBoundsException(no message) fromLucene90DocValuesProducer$TermsDict.seekExact, viaToParentDocValues$SortedDVs.lookupOrdandTermOrdValComparator.copy.
More files, environment, full output277 words
Copy the test into lucene/join/src/test/org/apache/lucene/search/join/ of a Lucene checkout and run the command below. The default codec is pinned because the exception type is codec-specific. The test makes no random choices; the test framework still randomizes its environment (similarity, locale, time zone), but the outcome does not depend on the seed (DEADBEEF and 5EED1234 fail the same way). The codec's exception has no message, so the test catches only that type and rethrows an AssertionError that states expected vs actual, with the exception as the cause. It asserts the hit count only, because the javadoc does not fix where such a parent sorts relative to others.
It passes :lucene:join:checkGoogleJavaFormat. Under gradle it fails with -Ptests.asserts=true and false on main and passes with the fix; on the standalone runner it fails on main (seeds DEADBEEF and 5EED1234), 10.5.0, 10.5.1, 10.5.2 and 10.6.0.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.5.0, 10.5.1, 10.5.2 and 10.6.0 release jars; 10.4.0 and 9.12.3 lack the constructor |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (the only platform it ran on) |
cp $REPRO/TestBlockJoinSortStringLastReverse.java $LUCENE/lucene/join/src/test/org/apache/lucene/search/join/
cd $LUCENE && ./gradlew :lucene:join:test --tests org.apache.lucene.search.join.TestBlockJoinSortStringLastReverse -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways
# gradle, fresh worktree at main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with false); module prefixes trimmed, ... = frames left out
TestBlockJoinSortStringLastReverse > testStringLastWithReverseChildren FAILED
java.lang.AssertionError: expected 1 sorted hit, got java.lang.IndexOutOfBoundsException
at __randomizedtesting.SeedInfo.seed([DEADBEEF:25E4A7E0D9AA9092]:0)
at org.apache.lucene.search.join.TestBlockJoinSortStringLastReverse.testStringLastWithReverseChildren(TestBlockJoinSortStringLastReverse.java:74)
Caused by:
java.lang.IndexOutOfBoundsException
at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer$TermsDict.seekExact(Lucene90DocValuesProducer.java:1814)
at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer$BaseSortedDocValues.lookupOrd(Lucene90DocValuesProducer.java:1682)
at org.apache.lucene.search.join.ToParentDocValues$SortedDVs.lookupOrd(ToParentDocValues.java:108)
at org.apache.lucene.search.comparators.TermOrdValComparator$TermOrdValLeafComparator.copy(TermOrdValComparator.java:354)
at org.apache.lucene.search.TopFieldCollector$TopFieldLeafCollector.collectAnyHit(TopFieldCollector.java:126)
...
at org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:678)
at org.apache.lucene.search.join.TestBlockJoinSortStringLastReverse.testStringLastWithReverseChildren(TestBlockJoinSortStringLastReverse.java:71)
# 10.5.2 and 10.6.0 release jars: the same message and frames (seekExact:1640 / :1817, copy:342 / :344)- The javadoc lists
STRING_LASTas a child missing value forType.STRINGthat "participates in the min/max selection", with no limit on the reverse flags. - The marker breaks the
SortedDocValuescontract:lookupOrdneeds0 <= ord < getValueCount(); here 2,147,483,647 against 1. - The only
STRING_LASTtest is commented "descending" but sorts children ascending.
The full argument265 words
- Contract.
childMissingValue: "The missing value for child documents that lack the sort field. This value participates in the min/max selection among siblings. Type constraints are the same as forparentMissingValue", and forType.STRING"useSortField#STRING_FIRSTorSortField#STRING_LAST" (ToParentBlockJoinSortField.java:139-145, and:106-108for the 7-argument constructor). Validation acceptsSTRING_LASTfor every reverse flag (:178-198). The 10.5.0 CHANGES entry: children with missing values "now correctly participate in the min/max selection according to the configured missing value" (lucene/CHANGES.txt:1187-1189). SortedDocValues. The ordinal "is dense, starts at 0" (SortedDocValues.java:41-42);lookupOrdneeds "ord ... >= 0 and < getValueCount()" (:51).- Review history. In the #15727 review the reviewer (romseygeek) asked that the wrapper "still needs to return
truefromadvanceExact()... And then return an appropriate value fromord()"; the contributor (gmarouli) answered "my initial thought is-1since any positive number could be confused with an actual ordinal", and the reviewer pointed toTermOrdValComparator's ownmissingOrd = Integer.MAX_VALUE(2026-02-24). In the comparator that value is internal state, used only for documents without an ordinal (TermOrdValComparator.java:221-228,:345-348);copy()andlookupOrdwere not discussed. - Tests.
TestBlockJoinSorting's onlySTRING_LASTchild case is commented "Sort by descending with missing values last in child level" but passesreverse = false(TestBlockJoinSorting.java:563-572), so max-child selection withSTRING_LASTnever ran through the sort field. - Loud, but not noise. The failure is a clean exception, but it needs no absurd parameter: three documented, validated settings and ordinary data (a block with a child that lacks the field).
The devil's-advocate check107 words
The case: the marker is deliberate. TestBlockJoinSelector pins ordValue() == Integer.MAX_VALUE for MAX with missing-last (:222, :224, :278, :282), the selector is @lucene.internal, and the sort field is @lucene.experimental (ToParentBlockJoinSortField.java:46).
Why it fails: those tests pin the internal selector's value and never call lookupOrd. The public sort field feeds that value, through documented and validated options, to a comparator that cannot handle it. @lucene.experimental allows API changes, not crashes on accepted input. The argument constrains the fix (keep the selector value), not the verdict; the cross-model intended-behaviour review, the devil's advocate and the adjudication all rated it weak.
Recommended Report the parent as having no value (a FilterSortedDocValues in ToParentBlockJoinSortField)
Report a parent whose missing-last child won the max as having no value: for max-child selection with child STRING_LAST, the sort field wraps the doc values so advanceExact returns false on Integer.MAX_VALUE. The test and the whole join module pass (105 tests, the new one included). Such a parent then sorts by parentMissingValue, which matches the javadoc only for STRING_LAST.
final boolean childMissingLast = childMissingValue == STRING_LAST;
SortedDocValues selected =
BlockJoinSelector.wrap(sortedSet, type, parents, toIter(children), childMissingLast);
if (type == BlockJoinSelector.Type.MAX && childMissingLast) {
// The selector reports "a child without a value, sorted last, won the max" as
// Integer.MAX_VALUE, which is not an ordinal: report such a parent as having no value.
return new FilterSortedDocValues(selected) {
@Override
public boolean advanceExact(int target) throws IOException {
return in.advanceExact(target) && in.ordValue() != Integer.MAX_VALUE;
}
};
}
return selected;All options and trade-offs3 options ยท 959 words
Three options; A was validated with gradle in a fresh scratch worktree at 645d4fe14a20, B and C were not executed. A stops the crash; B is the complete fix if the maintainers want a parent whose missing child won to keep its own position. Not a fix: clamping the marker to a real ordinal (getValueCount() - 1), which would turn the exception into a silently wrong sort key, the largest real term, in FieldDoc.fields.
Report the parent as having no value (a FilterSortedDocValues in ToParentBlockJoinSortField) (validated by the checks) (recommended)
In getStringComparator (ToParentBlockJoinSortField.java:228-229), only when max-child selection meets childMissingValue == STRING_LAST, wrap the selector's doc values in a FilterSortedDocValues whose advanceExact returns false when the selected ordinal is Integer.MAX_VALUE. TermOrdValComparator then applies its own missing handling and never looks the marker up. It is the hunt's crash-avoidance sketch, limited to the one case that produces the marker and with an import instead of a qualified name (diff: +15 / -2 lines, one file).
final boolean childMissingLast = childMissingValue == STRING_LAST;
SortedDocValues selected =
BlockJoinSelector.wrap(sortedSet, type, parents, toIter(children), childMissingLast);
if (type == BlockJoinSelector.Type.MAX && childMissingLast) {
// The selector reports "a child without a value, sorted last, won the max" as
// Integer.MAX_VALUE, which is not an ordinal: report such a parent as having no value.
return new FilterSortedDocValues(selected) {
@Override
public boolean advanceExact(int target) throws IOException {
return in.advanceExact(target) && in.ordValue() != Integer.MAX_VALUE;
}
};
}
return selected;Trade-offs
- No change elsewhere: only MAX with missing-last produces the marker, and no wrapper is added otherwise. The selector keeps returning
Integer.MAX_VALUE, soTestBlockJoinSelectoris untouched. - The comparator is built with
Pruning.NONEand reads each document's ordinal only throughadvanceExactthenordValue(TermOrdValComparator.java:309-315), so the filter covers every such read. - Order: such a parent sorts where
parentMissingValueputs parents without a valued child. That matches the javadoc whenparentMissingValue = STRING_LAST, not otherwise. Executed with the fix (blocks P1 = [b, missing], P2 = [c], P3 = [missing]; ascending parents, max child, childSTRING_LAST):parentMissingValue = STRING_LASTgives P2, P1, P3;nullorSTRING_FIRSTgive P1, P3, P2, where the javadoc implies P3, P2, P1. The cross-model reviewer's own two-parent test shows the same with the hunt's sketch (expected[second, first], got[first, second]). The order probe sorts parents ascending only; withreverseParents = truethe hit queue multiplies every comparison by -1 (FieldValueHitQueue.java:68,:144), which was not probed. - What the test covers: the minimal (regression) test asserts only the hit count, so it checks the crash, not the order; the order above comes from the separate order probe.
- By code reading this is what MIN with
STRING_FIRSTalready does: the wrapper returns-1(executed) andcopy()maps-1to the comparator's missing state (:345-348).
What the checks validated
Gradle in a fresh scratch worktree at 645d4fe14a20 (JDK 25.0.3, -Ptests.seed=DEADBEEF), 2026-10-30:
- without the fix the minimal test fails with
-Ptests.asserts=trueandfalse;git applyof the diff succeeds, and with it the test passes both ways; aftergit apply -Rit fails again; - the whole join module passes with the fix:
:lucene:join:test= 17 suites, 105 tests (the new test included), 3 skipped, 0 failures (among themTestBlockJoinSorting3,TestBlockJoinSelector4,TestBlockJoin18); :lucene:join:checkGoogleJavaFormatpasses with the fix and the test;- the diagram probe with fix A shadowed (
-eaand-da): the repro's search returns 1 hit withfields[0] = null, the 7-argumentreverse = trueform too, and the other combinations keep their values; - earlier, the hunt's sketch (the same logic without the MAX guard) passed the test on main (
-ea,-da) andTestBlockJoinSortingwithTestBlockJoinSelector, 7 of 7.
Keeps the documented order for every parentMissingValue. The raw ordinals cannot carry the third state as they are: within a segment compare (TermOrdValComparator.java:146), compareBottom (:332) and compareTop (:405) subtract one int ordinal from another, and with parentMissingValue null or STRING_FIRST (the cases B is for) the comparator's missing ordinal is -1 (:224-228), so Integer.MAX_VALUE - (-1) overflows to Integer.MIN_VALUE and flips the sign. Executed by the independent fact check (-ea and -da): a stock comparator over the selector, with only lookupOrd(Integer.MAX_VALUE) made non-throwing, one segment, ascending: parents R3 = [missing] then R1 = [b, missing] come back R1, R3, where the marker's meaning gives R3, R1; two other layouts happen to come out right. Only with parentMissingValue = STRING_LAST does no slot hold -1, the case fix A already places correctly. Across segments the slot value is missing too: cross-segment compare and compareValues use values[slot], with null meaning the parent missing value (:149-160, :188-198); FieldDoc.fields come from value(slot) (:183-185); searchAfter maps the top value back through lookupTerm (:230-243). Not executed as a fix.
Trade-offs
A BytesRef has no "after every term" value, so this needs a marker object, overflow-safe comparisons (Integer.compare) and changes to copy, compare, compareBottom, compareTop, compareValues and setTopValue, in TermOrdValComparator or a join-specific comparator. It would also have to settle what MIN with STRING_FIRST (ordinal -1) means when parentMissingValue = STRING_LAST. An API-level design choice for the maintainers.
Return "no value" (or -1) from ToParentDocValues.SortedDVs itself. Avoids the crash for every caller of the public BlockJoinSelector.wrap, with the same order trade-off as A. Not executed.
Trade-offs
Contradicts the TestBlockJoinSelector assertions that pin Integer.MAX_VALUE (:222, :224, :278, :282), which encode the #15727 design.
No known downstream product reaches this path: Solr's child-field sort uses the constructor without missing values, and Elasticsearch and OpenSearch implement nested sorting in their own code (read, not executed).
Found by reading how the 10.5.0 block-join missing-value code reports a missing child to the string comparator, then probing every reverse-flag and child-missing-value combination on the stock main jars: only max child with STRING_LAST threw. An independent reproducer, cross-model reviews, a devil's advocate and an adjudication confirmed it.
The method in full145 words
Method: code reading, then executed tests (the hunt's non-formal tier: the bug was seen in the code, not reported by a model checker).
- First signal (2026-10-26). A unit studying block-join code read the #15727 missing-ordinal handling in
ToParentDocValues(MAX selection againstmissingOrd = Integer.MAX_VALUE) next to howTermOrdValComparator.copy()treats ordinals. A hand-written probe over every reverse-flag x child-missing-value combination and a JUnit on the stock main jars confirmed it. The unit's bounded-exhaustive sort checker (every layout of up to 5 documents) had to exclude this combination because it throws, and is not credited as the finder. - Checks. An independent reproducer, an executed cross-model refute review and a reading-only intended-behaviour review (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication confirmed it; a follow-up swept the releases, searched for prior reports and assessed security; the repro was then reduced to one block and one search.
No earlier report found (searched 2026-10-26 and again 2026-10-30: GitHub issues and PRs incl. the JIRA archive, mailing lists, downstream trackers and the web). Closest: #15727, the change that added the option, whose review chose the Integer.MAX_VALUE marker without discussing lookupOrd.
Searches4 searches
Related, not this bug: #15727 (the introducing PR, no comment after the merge) and #15548 (its feature request); #16857 (merged 2026-10-29, comparator pruning, copy() untouched); #16887 / #16947 (a different document-block sorting bug: early termination); the same symptom from different code: #7232 (LUCENE-6170), #14855, #14517, OpenSearch #19636 and #19529; older selector defects #8922 (LUCENE-7871) and #7612 (LUCENE-6554). Tracking issue #16937 and findings list are not prior art; no maintainer has triaged LFV-223 there.
| Where | When | How | Found |
|---|---|---|---|
| Search tool (GitHub issues, PRs and commits incl. the migrated JIRA archive; dev@, java-user@, issues@, commits@, builds@; Solr, OpenSearch, Elasticsearch; web) | 2026-10-26, rerun 2026-10-30 | class names, lookupOrd, ordValue, missingOrd, childMissingValue, reverseChildren, STRING_LAST, "nested sort", "block join sort", "max child", IndexOutOfBoundsException, Integer.MAX_VALUE; 262 logged entries (210 distinct) in the first search, 164 in the rerun | no report of this bug (23 and 27 hits read) |
| GitHub (apache/lucene and all of GitHub) | 2026-10-26, 2026-10-30 | manual API searches (36, then 57), gh pr list by file and class name (16, then 22) | only #15727 and unrelated merged PRs |
| apache/lucene issue and PR dumps | 2026-10-26, 2026-10-30 | every issue and PR updated since 2026-02-01 (313 issues, 1,146 PRs) and again since 2026-10-20, with bodies, comments, reviews and review comments; no report can predate #15727 (opened 2026-02-19) | no mention of the bug |
| Open PRs | 2026-10-30 | all 408 open PRs listed; changed files of the 80 updated since 2026-10-20 | none changes the marker or copy(); #16847 touches TermOrdValComparator only to rename maxDoc() |
Introduced in 10.5.0 by #15727 (167503f6d84, backport 0915f9cac549, merged 2026-03-03), which added childMissingValue. Fails on 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main; 10.4.0 and 9.12.3 lack the constructor. On 10.4.0 jars the backport's three join files alone make it fail. Not fixed upstream.
Versions tested7 rows
Introduced by #15727 "Fix incorrect handling of missing values in ToParentBlockJoinSortField (#15548)" (gmarouli, merged by romseygeek on 2026-03-03; closes #15548): 167503f6d84d on main and the 10.x backport 0915f9cac549, which the releases contain. git blame puts ToParentDocValues.java:55, :97-102 and ToParentBlockJoinSortField.java:222, :228-229 on it, and git log --all -S on the marker, missingOrd, hasChildWithMissingValue and childMissingValue over lucene/join finds only these two commits. SortedDVs.lookupOrd's pass-through dates from LUCENE-7871 (2017), and copy() mapped only -1 to missing before 10.5.0 too. Of the 34 9.x/10.x release tags, only 10.5.0 to 10.6.0 contain childMissingValue. Upstream main 2f424fd428da, branch_11x and branch_10_6 carry the same code (read, 2026-10-30).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | fails with assertions on and off (gradle and the standalone runner) |
| 10.6.0 | affected | fails, -ea and -da (release jars) |
| 10.5.2, 10.5.0 | affected | fail, -ea and -da |
| 10.5.1 | affected | fails, -ea |
10.4.0 + the three join files of 0915f9cac549 | commit A/B | fails, -ea and -da: the join change alone is enough |
| 10.4.0, 9.12.3 | not applicable | the constructor does not exist; on 10.4.0 the closest older call (max child plus the parent-level setMissingValue(STRING_LAST)) returns both parents |
fix A (scratch worktree at 645d4fe14a20) | not affected | the minimal test and the whole join module (105 tests) pass |
The finder, an independent reproducer, cross-model reviews (one executed), a devil's advocate and an adjudication confirmed it; a follow-up ran the release matrix and a commit A/B. This doc re-ran the test and validated the fix with gradle.
Checks and verdicts254 words
The verifiers and reviews ran on main ea57360912ed; between it and 645d4fe14a20 no commit touches the files involved.
| Check | Verdict | What it found |
|---|---|---|
| finder (join / block-join unit) | candidate | Code reading, a probe over every reverse-flag x child-missing-value combination, a JUnit on the stock main jars and the crash-avoidance sketch. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Its 2 bug tests fail and its STRING_FIRST control passes on main with -ea, with -da and with a second seed; the sketch passes its crash test and fails its ordering test. |
| independent reproducer | real | Own public-API test with every non-parent document matching childFilter: fails on main, 10.5.2 and 10.5.0; controls over every other combination pass; 10.4.0 and 9.12.3 do not compile. |
| cross-model review, intended lens (OpenAI GPT-5.6, reading only) | real (high) | Intended argument weak: the pinned selector value does not cover the public sort field. |
| intended-behaviour devil's advocate | real | No intended, documented or misuse argument survives; measured ordValue 2147483647 against getValueCount 3; the 7-argument constructor fails too. |
| adjudication | confirmed (high); crash | Re-ran the finder's and the reproducer's tests with new seeds. |
| Follow-up (history, prior reports, security) | new; security none | Release sweep and commit A/B; two prior-report searches; a 6-test security probe (fails closed, no amplification, no data exposure). |
| repro minimization and this doc | reproduced, fix validated | One block, one search; gradle without and with fix A, the whole join module, the format check; ordinal and order probes. |
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 467 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,429 words
- Registry title
- ToParentBlockJoinSortField on a STRING field with max-child selection and childMissingValue=STRING_LAST throws IndexOutOfBoundsException when a parent has children both with and without the field
- Area
- join / ToParentBlockJoinSortField string sort (ToParentDocValues missing-child ordinals, since #15727)
- Priority
- Low, rank 670: Since 10.5.0 a STRING ToParentBlockJoinSortField with max-child selection and STRING_LAST throws IndexOutOfBoundsException on ordinary data, deterministic for users of that new option, which no downstream uses yet.
- Impact class
- crash
- Impact note
- Applications on 10.5.0 or later that sort parents with ToParentBlockJoinSortField on a STRING field with max-child selection (reverseChildren=true, or reverse=true on the 7-argument constructor) and the new childMissingValue=SortField.STRING_LAST get an unchecked IndexOutOfBoundsException instead of results from every search that copies into its top hits a parent whose block has a child with the field and a member without it, with -ea and -da, the default codec and settings and any index size; MIN selection, STRING_FIRST, a null child missing value and parents whose children all lack the field work, nothing is written or corrupted, the same searcher keeps answering other queries, and only 10.5.0-10.5.2, 10.6.0 (by reading) and main are affected because the option is new in 10.5.0.
- Summary
- You sort parent documents by a string field of their children, descending, with new ToParentBlockJoinSortField("s", STRING, reverse=true, null, SortField.STRING_LAST, parents, children) (or the 8-argument form with reverseChildren=true), and expect the search to return the parents with children that lack the field sorted as missing-last, because the javadoc lists STRING_LAST as a legal child missing value that 'participates in the min/max selection among siblings'. Instead, as soon as the search collects a parent that has one child with the field and another child without it, it throws IndexOutOfBoundsException from the doc-values terms dictionary (Lucene90DocValuesProducer$TermsDict.seekExact via ToParentDocValues$SortedDVs.lookupOrd), with -ea and -da. The cause is that the block-join wrapper reports 'a missing child, sorted last, won the max' as the ordinal Integer.MAX_VALUE, and the string comparator TermOrdValComparator treats every ordinal other than -1 as a real one and looks it up.
- Introduced
- 167503f6d84d8e43db4ec6ae3baf17337290393f on main (apache/lucene#15727 'Fix incorrect handling of missing values in ToParentBlockJoinSortField (#15548)', by gmarouli, merged by romseygeek; closes issue #15548), with the 10.x backport 0915f9cac549 (reachable from releases 10.5.0, 10.5.1, 10.5.2 and 10.6.0; its three join files are blob-identical to 167503f6d84's) [read: git blame of ToParentDocValues.java:55 and :97-102 and ToParentBlockJoinSortField.java:228-229 on main, and git log -S on 'Integer.MAX_VALUE : -1', missingOrd, hasChildWithMissingValue and childMissingValue over lucene/join on all refs returns only these two commits; exec: on stock 10.4.0 jars, shadowing only the three join files of 0915f9cac549 makes the repro throw the same exception, so the join change alone is sufficient]; the childMissingValue constructors arrived in the same commit, so the defect was in the option's first version; ToParentDocValues.SortedDVs.lookupOrd, which passes the ordinal through, is unchanged since 706d2018153d (LUCENE-7871, 2017) (2026-03-03)
- First affected version
- 10.5.0 [exec]
- Affected versions
- 10.5.0, 10.5.1 and 10.5.2 (the last release before 10.6.0), 10.6.0 (by reading, see the branch_10_6 check below) and main (11.0.0-SNAPSHOT) through ea57360912ed [exec: the finder repro fails 1 of 1 on 10.5.0, 10.5.1 and 10.5.2 (-ea; also -da on 10.5.2) and main (-ea and -da); the follow-up's 10.5 probe fails 2 of 3 (the 8- and 7-argument claim tests; its control over the 10 other reverseParents x reverseChildren x {null, STRING_FIRST, STRING_LAST} combinations passes) on 10.5.0, 10.5.1, 10.5.2 and main (-ea; also -da on main); the independent reproduction's test fails on 10.5.0 (-ea), 10.5.2 (-ea and -da) and main (-ea and -da)]; branch_10x (unreleased) and branch_10_6 (the branch of 10.6.0, released 2026-10-29) carry the identical join files and TermOrdValComparator.copy on branch_10_6 still maps only -1 to missing [read only, 2026-10-26, not executed]; not applicable: 10.4.0 and 9.12.3 [exec: the constructors with a child missing value do not exist there, so the repro does not compile, and the closest older configuration (max child plus SortField.setMissingValue(STRING_LAST)) returns both parents], and every older release [read: no childMissingValue in any 9.x/10.x release tag before 10.5.0, and git log --all -S childMissingValue finds only 167503f6d84 and its backport]
- 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: 262 logged read-only query entries, 210 distinct: 191 through a scripted prior-report search in three runs over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists, the LUCENE JIRA archive and the web, 36 manual GitHub API searches over apache/lucene, apache/solr, OpenSearch and Elasticsearch, 16 gh pr list searches by file and class name, and 19 lists.apache.org queries; plus a local grep of every apache/lucene issue (313) and PR (1146) updated since 2026-02-01 with bodies, comments, reviews and review comments, close to exhaustive for apache/lucene because no report can predate #15727 (opened 2026-02-19), the changed files of all 413 open PRs, and the full #15727 review thread; 23 hits read, none of them this bug); not fixed upstream (upstream main HEAD at search time was ea57360912ed; on main, branch_10x, branch_10_5 and branch_10_6 the last change to ToParentDocValues.java, ToParentBlockJoinSortField.java and BlockJoinSelector.java is #15727, and the 5 open PRs touching the bug's files, #16857, #14475, #14775, #14996 and #16847, change neither the missing ordinal nor TermOrdValComparator.copy()); origin, not a report: #15727 (closes #15548), whose review thread chose the Integer.MAX_VALUE sentinel by analogy with TermOrdValComparator's internal missingOrd without discussing lookupOrd; downstream, as far as read and not executed: Solr's childfield sort uses the 5-argument constructor without missing values and Solr main pins Lucene 10.4.0, and Elasticsearch and OpenSearch implement nested sorting in their own code, not with this class. Update 2026-11-02: #16857 (merged 2026-10-29 as 70a428b5ba9 on main and 203699279f5 on branch_11x, not on branch_10_6) edited TermOrdValComparator.java but not copy() or the missing ordinal; the repro still fails on e1faeba08bd (-ea, -da).
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-30: the minimal test with gradle in a fresh scratch worktree (fails with assertions on and off; with fix A it passes, together with the whole join module, 105 tests, and the join format check; after reverting the fix it fails again), plus an ordinal probe and an order probe with the fix shadowed (standalone runner, assertions on). Release jars: 10.5.0, 10.5.1, 10.5.2 and 10.6.0 fail; on 10.4.0 and 9.12.3 the constructor does not exist. JDK 25.0.3, Linux aarch64. The verifiers and cross-model reviews ran on mainea57360912ed, which differs from645d4fe14a20in no file involved.
- Finding record
a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ToParentDocValues.java:55 (missingOrd = sortMissingLast ? Integer.MAX_VALUE : -1), :96-103 (ordValue: MAX returns Math.max(ord, missingOrd) when a child is counted as missing), :106-109 (lookupOrd passes the ordinal through)a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:46 (@lucene.experimental), :95-111 and :128-148 (constructor javadoc: childMissingValue 'participates in the min/max selection among siblings', for Type.STRING use STRING_FIRST or STRING_LAST), :166-200 (validation accepts STRING_LAST for every reverse flag), :214-232 (string comparator: reverseChildren selects MAX and wraps with childMissingValue == STRING_LAST, then TermOrdValComparator)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java:343-355 (copy maps only -1 to missing and calls lookupOrd on every other ordinal); lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java:37-54 (ordValue dense from 0; lookupOrd ord must be >= 0 and < getValueCount()); lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1681-1684 and :1812-1814 (ordinal bounds check throws); lucene/join/src/java/org/apache/lucene/search/join/BlockJoinSelector.java:34 (@lucene.internal) and :96-131 (wrap)a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSorting.java:563-570 (the only STRING_LAST case, commented 'Sort by descending with missing values last in child level', passes reverse=false); TestBlockJoinSelector.java:222-224 and :278-282 (Integer.MAX_VALUE pinned at the selector level); lucene/CHANGES.txt:1154-1156 (10.5.0 entry for GITHUB#15727)
- The finding in 30 seconds
lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:120-128, (at 645d4fe14a20) :214-232lucene/join/src/java/org/apache/lucene/search/join/BlockJoinSelector.java:34, (at 645d4fe14a20) :96-132; lucene/join/src/java/org/apache/lucene/search/join/ToParentDocValues.java:55, :65-78, :96-109, :264-267, :310-311, :316-328, :333lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java:309-315, (at 645d4fe14a20) :324-341, :343-358; lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:116, :126; lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1681-1684, :1812-1814
- Why it looks unintended
lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:46, (at 645d4fe14a20) :106-108, :139-145, :178-198; lucene/CHANGES.txt:1187-1189lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java:41-42, (at 645d4fe14a20) :51; lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java:221-228, :345-348lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSorting.java:563-572; (at 645d4fe14a20) lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSelector.java:222, :224, :278, :282
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java:144-160, (at 645d4fe14a20) :183-198, :230-243, :309-315, :324-341, :345-348; lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSelector.java:222, (at 645d4fe14a20) :224, :278, :282lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java:146, (at 645d4fe14a20) :224-228, :332, :405; lucene/core/src/java/org/apache/lucene/search/FieldValueHitQueue.java:68, :144
- TestBlockJoinSortStringLastReverse.java (lucene/join, package org.apache.lucene.search.join, 1 test):
repro/LFV-223/src/org/apache/lucene/search/join/TestBlockJoinSortStringLastReverse.java(79 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an executed probe on the stock main jars (assertions on and off, and with fix A).
assets/LFV-223/bug.png(467 KB)
