All findings · Lucene finding · LFV-223 · tpbj-sortfield-string-last-max-child-ord-out-of-range

ToParentBlockJoinSortField with a STRING field, max-child selection and childMissingValue = STRING_LAST throws IndexOutOfBoundsException: the missing-last marker is looked up as a term

Since 10.5.0, a STRING ToParentBlockJoinSortField with max-child selection and childMissingValue = STRING_LAST throws IndexOutOfBoundsException once a parent has one child with the field and one without. The join code's "missing child won" marker, Integer.MAX_VALUE, is looked up as a term ordinal.

Summary

Priority
Low, rank 670Since 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
crash
Since
10.5.0 (#15727), the release that added childMissingValueintroduced 2026-03-03
Affected
10.5.0 to 10.6.0, and main
Status
documented
Found by
code reading of the block-join sort code next to the string comparator; confirmed by executed tests

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

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.

Background: sorting parents by a child field

  1. A block is a group of child documents followed by their parent, added together with addDocuments. ToParentBlockJoinQuery matches children and returns their parents.
  2. ToParentBlockJoinSortField sorts those parents by a child field: reverseChildren = false takes the lowest child value, true the highest. Since 10.5.0, childMissingValue (STRING_FIRST or STRING_LAST for strings) lets children without the field take part in that choice.
  3. 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 with lookupOrd(ordinal).
Diagram of the minimal repro on Lucene main 645d4fe14a20: one block (a child with s = b, a child without s, the parent) sorted by ToParentBlockJoinSortField with reverseChildren = true and childMissingValue = STRING_LAST. The join wrapper's ordValue() for the parent is 2147483647 (Integer.MAX_VALUE) while the field has 1 value; TermOrdValComparator.copy calls lookupOrd(2147483647) and the doc-values terms dictionary throws IndexOutOfBoundsException.
How the one-block repro reaches the exception: the missing child wins the max, the join wrapper reports ordinal 2,147,483,647, and the string comparator looks it up in a field that has 1 term. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index one block: a child with s = "b", a child without s, and their parent. You sort the parents with ToParentBlockJoinSortField("s", STRING, false, true, null, SortField.STRING_LAST, parents, children): the highest child value decides (reverseChildren = true), and a child without s counts as "missing, sorts last".
  2. You expect 1 hit, the parent. The missing child beats b in the max, so the parent's sort key is "after every value".
  3. You get IndexOutOfBoundsException from the doc-values terms dictionary, with assertions on and off. The 7-argument constructor with reverse = true fails the same way.
  4. 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.
  5. Only Type.STRING with max-child selection and child STRING_LAST fails, on 10.5.0 to 10.6.0 and main. MIN selection, STRING_FIRST, a null child missing value and parents whose children all lack the field do not throw.

Who is affected

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.

The code (main 645d4fe14a20)

  • Which comparator. ToParentBlockJoinSortField.getStringComparator (ToParentBlockJoinSortField.java:214-232) builds new TermOrdValComparator(numHits, field, missingValue == STRING_LAST, getReverse(), Pruning.NONE) (:215-216, where missingValue is the parent missing value), selects BlockJoinSelector.Type.MAX when reverseChildren (:221-222) and wraps the doc values with sortMissingLast = (childMissingValue == STRING_LAST) (:228-229). The 7-argument constructor passes reverse as both reverseParents and reverseChildren (:120-128).
  • The marker. BlockJoinSelector.wrap (BlockJoinSelector.java:96-132, @lucene.internal at :34) reaches ToParentDocValues.SortedDVs, which sets missingOrd = 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() returns Math.min(ord, missingOrd) or Math.max(ord, missingOrd) (:96-104); for MAX with missing-last that is Integer.MAX_VALUE. lookupOrd passes any ordinal straight through (:106-109).
  • Executed on the repro's index: at the parent, MAX + missing-last gives ordValue() = 2147483647 with getValueCount() = 1; MIN + missing-first gives -1; the other two combinations give the real ordinal 0.
  • The throw. TermOrdValComparator.copy() (TermOrdValComparator.java:343-358) maps only -1 to its missing state (:345-348), asserts ord >= 0 (:350) and calls termsIndex.lookupOrd(ord) (:354). Lucene90DocValuesProducer checks the ordinal in TermsDict.seekExact and throws IndexOutOfBoundsException (:1812-1814); a plain throw, so -da fails too.
  • When. TopFieldCollector calls copy() 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.min keeps the real ordinal), STRING_FIRST with MAX (Math.max(ord, -1)), a null child missing value, and parents whose children all lack the field (advanceExact returns false, ToParentDocValues.java:316-328).
Exposure and likelihood202 words
  • Who is hit. ToParentBlockJoinSortField on Type.STRING with reverseChildren = true (or reverse = true on the 7-argument constructor) and childMissingValue = SortField.STRING_LAST, on 10.5.0 or later. Default codec and settings; the SimpleText format 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 childFilter also 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 IndexOutOfBoundsException instead 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 = null or STRING_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.

Minimal repro

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.

TestBlockJoinSortStringLastReverse.java (lucene/join, package org.apache.lucene.search.join, 1 test)79 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.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);
        }
      }
    }
  }
}
Command
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) from Lucene90DocValuesProducer$TermsDict.seekExact, via ToParentDocValues$SortedDVs.lookupOrd and TermOrdValComparator.copy.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform it ran on)
Command (environment)
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

Observed output

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

Why it looks unintended

  • The javadoc lists STRING_LAST as a child missing value for Type.STRING that "participates in the min/max selection", with no limit on the reverse flags.
  • The marker breaks the SortedDocValues contract: lookupOrd needs 0 <= ord < getValueCount(); here 2,147,483,647 against 1.
  • The only STRING_LAST test 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 for parentMissingValue", and for Type.STRING "use SortField#STRING_FIRST or SortField#STRING_LAST" (ToParentBlockJoinSortField.java:139-145, and :106-108 for the 7-argument constructor). Validation accepts STRING_LAST for 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); lookupOrd needs "ord ... >= 0 and < getValueCount()" (:51).
  • Review history. In the #15727 review the reviewer (romseygeek) asked that the wrapper "still needs to return true from advanceExact() ... And then return an appropriate value from ord()"; the contributor (gmarouli) answered "my initial thought is -1 since any positive number could be confused with an actual ordinal", and the reviewer pointed to TermOrdValComparator's own missingOrd = 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() and lookupOrd were not discussed.
  • Tests. TestBlockJoinSorting's only STRING_LAST child case is commented "Sort by descending with missing values last in child level" but passes reverse = false (TestBlockJoinSorting.java:563-572), so max-child selection with STRING_LAST never 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.

Suggested fix

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, so TestBlockJoinSelector is untouched.
  • The comparator is built with Pruning.NONE and reads each document's ordinal only through advanceExact then ordValue (TermOrdValComparator.java:309-315), so the filter covers every such read.
  • Order: such a parent sorts where parentMissingValue puts parents without a valued child. That matches the javadoc when parentMissingValue = STRING_LAST, not otherwise. Executed with the fix (blocks P1 = [b, missing], P2 = [c], P3 = [missing]; ascending parents, max child, child STRING_LAST): parentMissingValue = STRING_LAST gives P2, P1, P3; null or STRING_FIRST give 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; with reverseParents = true the 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_FIRST already does: the wrapper returns -1 (executed) and copy() maps -1 to 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=true and false; git apply of the diff succeeds, and with it the test passes both ways; after git apply -R it 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 them TestBlockJoinSorting 3, TestBlockJoinSelector 4, TestBlockJoin 18);
  • :lucene:join:checkGoogleJavaFormat passes with the fix and the test;
  • the diagram probe with fix A shadowed (-ea and -da): the repro's search returns 1 hit with fields[0] = null, the 7-argument reverse = true form 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) and TestBlockJoinSorting with TestBlockJoinSelector, 7 of 7.

Give the comparator a separate "selected child sorts last" state

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.

Map the marker inside ToParentDocValues

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.

Downstream note

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

How it was found

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 against missingOrd = Integer.MAX_VALUE) next to how TermOrdValComparator.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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
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-30class 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 rerunno report of this bug (23 and 27 hits read)
GitHub (apache/lucene and all of GitHub)2026-10-26, 2026-10-30manual 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 dumps2026-10-26, 2026-10-30every 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 PRs2026-10-30all 408 open PRs listed; changed files of the 80 updated since 2026-10-20none changes the marker or copy(); #16847 touches TermOrdValComparator only to rename maxDoc()

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails with assertions on and off (gradle and the standalone runner)
10.6.0 affectedfails, -ea and -da (release jars)
10.5.2, 10.5.0 affectedfail, -ea and -da
10.5.1 affectedfails, -ea
10.4.0 + the three join files of 0915f9cac549 commit A/Bfails, -ea and -da: the join change alone is enough
10.4.0, 9.12.3 not applicablethe 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 affectedthe minimal test and the whole join module (105 tests) pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (join / block-join unit) candidateCode 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 realOwn 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 realNo intended, documented or misuse argument survives; measured ordValue 2147483647 against getValueCount 3; the 7-argument constructor fails too.
adjudication confirmed (high); crashRe-ran the finder's and the reproducer's tests with new seeds.
Follow-up (history, prior reports, security) new; security noneRelease 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 validatedOne block, one search; gradle without and with fix A, the whole join module, the format check; ordinal and order probes.

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

Finding record

LFV-223, snapshot of 2026-11-06

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 main ea57360912ed, which differs from 645d4fe14a20 in no file involved.

Lucene and upstream sources

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-232
  • lucene/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, :333
  • lucene/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-1189
  • lucene/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-348
  • lucene/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, :282
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an executed probe on the stock main jars (assertions on and off, and with fix A).

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.