All findings · Lucene finding · LFV-220 · tpbj-sortfield-filtered-out-siblings-forced-missing

ToParentBlockJoinSortField sorts a parent as missing when its block holds a document outside childFilter

Since 10.5.0, a document outside childFilter makes ToParentBlockJoinSortField treat its parent as having a child without a value, even when that document has the field (if the parent has a childFilter child with a value). Two blocks in which every child has the field sort P1=null P2=d, not P2=d P1=y.

Summary

Priority
Medium, rank 46A 10.5.0 regression makes ToParentBlockJoinSortField sort a parent as missing whenever its block holds any document outside childFilter, silently reordering parents in the documented multi-child-type layout, while its only downstream user (Solr) has not upgraded yet.
Impact
silent wrong results
Since
10.5.0 (#15727, 2026)introduced 2026-03-03
Affected
10.5.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
code reading (while writing a reference model for an exhaustive sort check)

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

childFilter picks which non-parent documents of a block take part in the sort. Lucene takes the min or max over exactly those documents, but its check for a child without a value counts every non-parent document of the block.

Summary card titled "ToParentBlockJoinSortField sorts a parent as missing when its block holds a document outside childFilter" (Apache Lucene 10.5.0 to 10.5.2 and main). The index: block 1 with doc 0 type:a s=y n=40 (in childFilter), doc 1 type:b s=x n=7 (outside it, dashed red outline, but has s and n) and parent P1; block 2 with doc 3 type:a s=d n=10 and parent P2. Steps: per parent, the MIN of s over childFilter docs with a value gives P1 y and P2 d, and doc 1 is never visited; totalChildren counts docs 0 and 1 of P1's block, 2; hasChildWithMissingValue = 1 < 2 = true although no child lacks s; ordValue = Math.min(2, -1) = -1, so P1's sort value is null and it sorts first; LONG with childMissingValue 0 gives Math.min(40, 0) = 0. Expected P2=d P1=y and P2=10 P1=40 (main with the fix; the STRING order also on 10.4.0); actual P1=null P2=d and P1=0 P2=10 (10.5.0 to 10.5.2, main). Notes on affected and unaffected settings, the 10.5.0 regression (#15727), the executed fix, and the childFilter and childMissingValue javadoc.
Two blocks in one segment, childFilter = type:a. The min over the type:a children is right, but the missing-value check counts both non-parent documents of P1's block, so P1 sorts first with null (and with 0 for LONG). Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index two blocks, parent last: P1 = [type:a s=y n=40, type:b s=x n=7, parent P1] and P2 = [type:a s=d n=10, parent P2]. Every child has both fields.
  2. Search the parents of the type:a children, sorted by new ToParentBlockJoinSortField("s", STRING, false, parents, childFilter) with childFilter = type:a: each parent sorts by the smallest s of its type:a children.
  3. You expect P2=d P1=y, which is what 10.4.0 returns: the type:b document is outside childFilter, so it takes no part.
  4. Instead 10.5.0 to 10.5.2 and main return P1=null P2=d. To decide whether a child lacked a value, Lucene compares 1 (type:a children with a value) with 2 (all non-parent documents of P1's block), so P1 sorts as missing, and missing sorts first.
  5. Numeric sorts too: LONG with childMissingValue=0 gives P1=0 P2=10 instead of P2=10 P1=40. No error; the hits and the hit count do not change.

Who is affected

Users of ToParentBlockJoinSortField on 10.5.0-10.5.2, 10.6.0 (by reading) or main whose childFilter leaves out some documents of a block: several child types per block, a layout the join package documents, or grandchildren. Hit: the default ascending STRING sort, and numeric sorts whose childMissingValue wins. Five documents suffice. Solr's childfield sort would be hit, but Solr still ships Lucene 10.4.0.

Mechanism, terms and numbers719 words

ToParentBlockJoinSortField sorts block-join parents by a field of their children, and childFilter selects which non-parent documents of a block take part. Since #15727 (10.5.0) it also applies a missing value when a child lacks a value. To decide that, ToParentDocValues compares the number of childFilter documents that have a value with the number of all non-parent documents in the block. So in a block where at least one childFilter document has a value, any other document outside childFilter, with or without the field, flags the parent, and wherever the missing value wins the min/max the parent sorts as missing: null and first under the default ascending STRING sort, childMissingValue under a numeric sort. In the repro every non-parent document has the field and 10.4.0 sorts correctly; 10.5.0 to 10.5.2 and main do not. No error is raised, and the hits, the hit count and the index are unchanged.

Terms

  • Block: documents indexed together with IndexWriter.addDocuments, parent last. The join package calls the last document of each block the parent and "all others" child documents (package-info.java:30-31).
  • ToParentBlockJoinSortField(field, type, reverse, parentFilter, childFilter) (@lucene.experimental): sorts parents by the smallest (ascending) or largest (descending) value of field among the parent's children selected by childFilter. Two constructors added by #15727 also take a childMissingValue, "The missing value for child documents that lack the sort field".

The bug

  1. Each comparator passes toIter(childFilter.getBitSet(context)), a BitSetIterator, to BlockJoinSelector.wrap, which builds a ToParentDocValues (ToParentBlockJoinSortField.java:220-229 for STRING, the numeric types alike).
  2. ToParentDocValues intersects the children with the doc values (childWithValues, ToParentDocValues.java:216) and takes the min or max over those documents only, counting them. So far only childFilter documents take part, which is correct.
  3. It then sets hasChildWithMissingValue = childrenWithValuesCount < totalChildren with totalChildren = docID - prevParentDocID - 1, every non-parent document of the block (nextDoc :265-267, advanceExact :310-311 and :333). For P1: 1 < 2, so true, though no child lacks the field. advanceExact, the path the top-hits comparators use (TermOrdValComparator.java:309-310, LongComparator.java:79-80), returns false before this comparison when no childFilter document of the block has a value (ToParentDocValues.java:316-318, :326-328), so only parents with at least one such document are flagged.
  4. The flag replaces the value. STRING: ordValue() returns Math.min(ord, missingOrd) for MIN and Math.max(ord, missingOrd) for MAX (:96-103), with missingOrd -1, or Integer.MAX_VALUE for childMissingValue = STRING_LAST. For P1, Math.min(2, -1) = -1: no value, FieldDoc value null, sorted first. Numeric: longValue() returns Math.min/max(value, childMissingValue) when childMissingValue is set (:187-195); for P1, Math.min(40, 0) = 0.

git blame attributes the count and comparison lines of step 3 (265-267, 311 and 333) to #15727; line 310, which finds the previous parent, dates from 2017. Before it, ToParentDocValues had no missing-value logic, so a document outside childFilter could not affect a parent.

The numbers

One segment, default codec, CheckJoinIndex passes; query ToParentBlockJoinQuery(type:a, parents, ScoreMode.None).

blockdocumentsin childFilter (type:a)
P1doc 0 type:a s=y n=40, doc 1 type:b s=x n=7, doc 2 parentdoc 0 only
P2doc 3 type:a s=d n=10, doc 4 parentdoc 3
sortexpected10.5.0, 10.5.2, main
STRING ascending, 5-argument constructorP2=d P1=y (10.4.0 returns this)P1=null P2=d
LONG ascending, childMissingValue=0P2=10 P1=40P1=0 P2=10

P1=0 is neither doc 0's 40 nor doc 1's 7: the missing value replaced a real one.

Wider cases (an independent verifier, executed on main, 10.5.0 and 10.5.2)

  • An excluded sibling without the field: A=null B=c instead of B=c A=z.
  • Three-level blocks with childFilter on the child level: every parent that has a grandchild sorts as missing (A=null B=null C=m instead of B=c C=m A=z; run with grandchildren that lack the field).
  • The index of upstream's TestBlockJoinSorting.testNestedSorting (every child has field2; rebuilt by verifiers), childFilter filter_1:T, sorted ascending: d=null i=null a=a b=c c=e g=k instead of a=a b=c c=e d=g g=k i=m, which 10.4.0 and 9.12.3 return. Upstream checks only the descending order, where Math.max(ord, -1) hides the defect.

On 10.4.0 and 9.12.3 the verifier's full STRING suite reports 7 tests and 1 failure: testInFilterChildWithoutValueIsMissing expects #15727's fix for a childFilter child without a value (the #15548 bug that #15727 fixed). The cases above pass there.

Exposure and likelihood351 words
  • Who is exposed. Applications that sort block-join parents with ToParentBlockJoinSortField and a childFilter that does not select every non-parent document of the block: several child types in one block (for example comments and reviews under one product), which the join package documents ("all rows for all joined tables matching a single row of the primary table must be indexed as a single document block", package-info.java:26-28) and which upstream's own testNestedSorting fixture uses; or multi-level blocks with childFilter on one level.
  • Affected settings. MIN and MAX are the child-level selection (reverse, or reverseChildren in the 8-argument constructor, ToParentBlockJoinSortField.java:221-222). STRING ascending with missing-first, the default of the 5-argument constructor, and STRING ascending with an explicit childMissingValue = null, which also acts as missing-first (:229) although the javadoc says null skips children without a value (:108, :145); numeric types with a childMissingValue that wins the min/max (for example 0 ascending, Long.MAX_VALUE descending); STRING descending with childMissingValue = STRING_LAST, where a related bug (LFV-223) throws IndexOutOfBoundsException instead.
  • Not affected. STRING descending with missing-first and STRING ascending with STRING_LAST (the missing marker cannot win), and numeric sorts with childMissingValue = null.
  • Default or opt-in. The class is opt-in and @lucene.experimental, and a selective childFilter is the application's choice. Nothing else is needed: default codec, config and searcher, assertions on or off; CheckJoinIndex accepts the index.
  • Scale. None: 5 documents in one segment. Deterministic: every returned parent whose block holds a childFilter document with a value and a document outside childFilter is flagged, and under the affected settings gets the missing value, on every query.
  • What you see. No error. A wrong parent order and wrong FieldDoc sort values (null for STRING, childMissingValue for numeric types). The hits, totalHits and the index do not change, and parents whose blocks hold no excluded document keep their values.
  • Downstream (read, not run). Solr's sort=childfield(...) builds the 5-argument constructor, so childfield(<string field>) asc would be affected, but Solr main pins Lucene 10.4.0. Elasticsearch and OpenSearch do not use this class. That says nothing about private code.

Minimal repro

The test shows that ToParentBlockJoinSortField (STRING, ascending, childFilter type:a) sorts parent P1 as if it had a child without a value only because its block also holds a type:b sibling outside childFilter, although every document has the sort field.

TestBlockJoinSortFilteredOutSibling.java (JUnit test)86 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.StoredField;
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.FieldDoc;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
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.util.BytesRef;

public class TestBlockJoinSortFilteredOutSibling extends LuceneTestCase {

  private static Document child(String type, String s) {
    Document d = new Document();
    d.add(new StringField("type", type, Field.Store.NO));
    d.add(new SortedDocValuesField("s", new BytesRef(s)));
    return d;
  }

  private static Document parent(String id) {
    Document d = new Document();
    d.add(new StringField("type", "parent", Field.Store.NO));
    d.add(new StoredField("id", id));
    return d;
  }

  public void testStringSortIgnoresSiblingOutsideChildFilter() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocuments(List.of(child("a", "y"), child("b", "x"), parent("P1")));
        w.addDocuments(List.of(child("a", "d"), parent("P2")));
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        BitSetProducer parents =
            new QueryBitSetProducer(new TermQuery(new Term("type", "parent")));
        Query typeA = new TermQuery(new Term("type", "a"));
        SortField sort =
            new ToParentBlockJoinSortField(
                "s", SortField.Type.STRING, false, parents, new QueryBitSetProducer(typeA));
        ToParentBlockJoinQuery query = new ToParentBlockJoinQuery(typeA, parents, ScoreMode.None);
        StringBuilder actual = new StringBuilder();
        for (ScoreDoc hit : searcher.search(query, 10, new Sort(sort)).scoreDocs) {
          BytesRef s = (BytesRef) ((FieldDoc) hit).fields[0];
          actual
              .append(searcher.storedFields().document(hit.doc).get("id"))
              .append('=')
              .append(s == null ? null : s.utf8ToString())
              .append(' ');
        }
        // expected: P2 (min d) before P1 (min y); actual: P1 first, as if a type:a child lacked s
        assertEquals("P2=d P1=y", actual.toString().trim());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-220/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/join/build/libs/lucene-join-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.join.TestBlockJoinSortFilteredOutSibling
Expected vs actual
Expected P2=d P1=y (MIN over the type:a children, as on 10.4.0); actual: P1=null P2=d, with -ea and -da.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<P[2=d P1=y]> but was:<P[1=null P2=d]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.join.TestBlockJoinSortFilteredOutSibling.testStringSortIgnoresSiblingOutsideChildFilter(TestBlockJoinSortFilteredOutSibling.java:82)

Why it looks unintended

  • The javadoc: childFilter "defines which child documents participates in sorting"; childMissingValue is for children "that lack the sort field". None does here.
  • Only a split reading fits (values from childFilter, "missing" from the whole block), and it treats doc 1, which has the field, as lacking it.
  • A regression: 10.4.0 sorts the same STRING case correctly.
The full argument296 words
  • The contracts. @param childFilter "Filter that defines which child documents participates in sorting." (ToParentBlockJoinSortField.java:63, :83, :110, :147); @param childMissingValue "The missing value for child documents that lack the sort field. This value participates in the min/max selection among siblings." (:106-108, :143-145).
  • BlockJoinSelector agrees. Its wrap methods "select one value per parent among its {@code children}" (BlockJoinSelector.java:91-95, :116-120), where children is the iterator the caller passes; its Bits path decides whether "any of the children of the given parent document has a value" by walking only the supplied children (:51-89).
  • CHANGES for #15727 (lucene/CHANGES.txt:1154-1156): "fix incorrect handling of children missing values. Children with missing values now correctly participate in the min/max selection according to the configured missing value." Nothing about documents outside childFilter; #15727's body, 2 comments, 14 reviews and 19 review comments and its issue #15548 never mention them.
  • Upstream tests. testNestedSorting (TestBlockJoinSorting.java:45, :284-296) uses a selective childFilter on mixed blocks and expects the excluded siblings' values to be ignored, but checks only the descending order, where Math.max(ord, -1) hides the missing flag, so it cannot show whether excluded documents were meant to affect "missing". TestBlockJoinSelector.testSortedSelector (:113, from 2016) carries missing-value assertions that #15727 added (lines 169-227): :176-179 expect ord -1 for two parents whose only value-less documents are outside its children set. Inside those two blocks no out-of-filter document has a value, so it says nothing about the valued case.
  • Regression (STRING sort; the numeric childMissingValue constructors are new in 10.5.0, so that case was wrong from the start). 10.4.0, 10.3.2, 10.0.0 and 9.12.3 sort the valued and the field-less layouts correctly; reverting #15727's three classes over 10.5.0 or main makes the test pass.
The devil's-advocate check356 words

The strongest case for "intended" was made by the finder's cross-model refute review (an independent reviewer from another model family, OpenAI GPT-5.6, which reproduced the behaviour and agreed on the root cause) and again by a GPT-5.6 reviewer arguing "works as designed": a split reading of "child". In the refute review's words, "childFilter restricts which existing values are selected, while structural block membership determines whether the parent has any missing child". That reading gives exactly P1=null and P1=0, which is what the code does. In its support: package-info calls every non-parent document of a block a child; #15727 counts children structurally from the PR commit that first adds missing-value detection (e781ce73d4, the third of the PR's 16 commits); testSortedSelector asserts the block-wide count; CHANGES says children with missing values now participate; the class is @lucene.experimental and BlockJoinSelector is @lucene.internal. The finder first accepted this and filed the finding as a documentation question.

Why it fails for the valued case: the split reading treats doc 1, which has s and n, as a child that lacks the sort field, while childMissingValue is documented as "The missing value for child documents that lack the sort field"; it counts doc 1 for the missing check but drops its value. A document outside childFilter then takes part in the sort through its mere presence, against "@param childFilter Filter that defines which child documents participates in sorting." Neither plain reading gives the result: with only childFilter documents as children P1 is y (LONG 40), with every non-parent document as a child P1 would be x (LONG 7). For the STRING sort it is a regression from 10.4.0, also on upstream's own fixture sorted ascending, and nothing in #15727's CHANGES entry, body, comments or reviews records a decision about documents outside childFilter. testSortedSelector has no valued out-of-filter document in the blocks it asserts, and the descending-only testNestedSorting cannot tell either way. @lucene.experimental marks an unstable API, not permission to return wrong results.

What remains open: for an excluded document that lacks the field, upstream may still decide the block-wide count is intended, because testSortedSelector encodes it.

Suggested fix

Recommended A: count only the childFilter documents of the block

ToParentDocValues keeps the BitSet behind its children iterator and counts only those documents when deciding whether a child lacked a value (+24/-2 lines). The test and the whole join module pass, with assertions on and off, once four value-less documents join TestBlockJoinSelector's children set, a change that keeps every expectation.

this.childBits = children instanceof BitSetIterator bsi ? bsi.getBitSet() : null; // constructor
int totalChildren = countChildren(prevParentDocID, docID); // was: docID - prevParentDocID - 1

private int countChildren(int prevParentDocID, int parentDocID) {
  if (childBits == null) return parentDocID - prevParentDocID - 1; // not a BitSetIterator
  int count = 0;
  for (int doc = prevParentDocID + 1; doc < parentDocID; doc++) {
    doc = childBits.nextSetBit(doc, parentDocID);
    if (doc == NO_MORE_DOCS) break;
    count++;
  }
  return count;
}
All options and trade-offs4 options ยท 706 words

One fix was built and validated in a scratch worktree at ea57360912ed; the other options come from the verifiers, the adjudication and the security assessment and were not run.

A: count only the childFilter documents of the block (validated by the checks) (recommended)

ToParentDocValues remembers the BitSet behind the BitSetIterator it is given and counts the set bits of the block with nextSetBit, instead of docID - prevParentDocID - 1, in nextDoc and advanceExact; the advanceExact count moves below its early returns. +24/-2 lines in ToParentDocValues.java. The upstream test TestBlockJoinSelector.testSortedSelector needs docs 1, 11, 13 and 14 (no value) in its children set (+5 lines), which keeps every expectation and passes on stock main too.

this.childBits = children instanceof BitSetIterator bsi ? bsi.getBitSet() : null; // constructor
int totalChildren = countChildren(prevParentDocID, docID); // was: docID - prevParentDocID - 1

private int countChildren(int prevParentDocID, int parentDocID) {
  if (childBits == null) return parentDocID - prevParentDocID - 1; // not a BitSetIterator
  int count = 0;
  for (int doc = prevParentDocID + 1; doc < parentDocID; doc++) {
    doc = childBits.nextSetBit(doc, parentDocID);
    if (doc == NO_MORE_DOCS) break;
    count++;
  }
  return count;
}

Trade-offs

  • Cost. Per collected parent, one nextSetBit call per childFilter document of its block plus at most one final call that finds none, where the stock code does one subtraction. In the worst case that is linear in the block size, like the existing walk over the children that have a value, but it also visits childFilter documents without a value, so with sparse doc values it can cost more. No benchmark was run.
  • Fallback. Callers that pass a children iterator other than a BitSetIterator keep today's block-wide count. All in-tree callers pass toIter(children) (ToParentBlockJoinSortField.java:229, :256, :283, :313, :352), and BlockJoinSelector is @lucene.internal; option D removes the fallback.
  • Safe bounds. The loop keeps nextSetBit's start below its end (a start == end call is a separate bug, LFV-032).
  • Behaviour change beyond the valued case. An excluded document without the field no longer forces the missing value either. That is the design question, and the reason testSortedSelector needs its fixture changed.
  • Side effect. It removes the widened trigger of the related IndexOutOfBoundsException (LFV-223) when the only 'missing' document is outside childFilter, not that bug itself.

What the checks validated

  • Stock: the test fails 2 of 2 with assertions on and off; the modified TestBlockJoinSelector passes 4 of 4.
  • Fix alone: the test and TestBlockJoinSorting pass; testSortedSelector fails at line 177 (expected:<-1> but was:<3>), the block-wide expectation.
  • Fix plus the fixture change, :lucene:join:test: 17 classes, 106 tests, 0 failures, 3 skipped (@Nightly, in suites that do not use these classes), with assertions on and off; a second seed also passed.
  • :lucene:join:tidy left the fix unchanged; :lucene:join:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • Standalone harness with the patched class: the test 2/2 (assertions on and off); the independent verifier's STRING (7/7, including three-level blocks and the upstream fixture ascending), numeric (6/6) and LFV-223 interaction (1/1) tests; the security probe with its fixed expectations (2/2).

B: count only block documents that lack the field (untested)

Treat a parent as missing only when some document of its block has no value.

Trade-offs

Fixes the valued case and keeps testSortedSelector unchanged, but leaves the field-less case, and with it the one-bit signal for left-out documents without the field (the security assessment prefers option A). Needs a value lookup for block documents outside childFilter, which the current single conjunction does not give, so it is harder to write than A.

C: document the block-wide count (not recommended)

State that every non-parent document of the block counts as a child for the missing-value check.

Trade-offs

Contradicts "Filter that defines which child documents participates in sorting", does not explain the valued case (a document whose value is ignored still forces the missing value), and keeps the 10.4 to 10.5 change.

D: pass the children BitSet instead of an iterator (untested, larger)

Give ToParentDocValues the BitSet itself through new BlockJoinSelector.wrap overloads, so the count never depends on the iterator's class.

Trade-offs

Removes option A's fallback; changes an @lucene.internal API with five call sites in ToParentBlockJoinSortField.

Downstream note

Why: the count that decides 'missing' includes the documents childFilter leaves out, so where that filter hides children from some viewers, a hidden child changes its parent's sort value and rank: one bit, never its value. Out of scope of SECURITY.md: authorization is the application's job, and no documented untrusted-input guarantee is broken.

How it was found

Code reading. An AI tool writing a reference model of the sort for a bounded-exhaustive check noticed that the child count ignores childFilter, and a JUnit test on the stock jar confirmed it. The exhaustive check, run afterwards with the javadoc's reading, reproduced it, and found nothing else in 617,760 checks.

The method in full182 words

Method: code reading (the hunt's non-formal tier).

  • A hunt unit (join and block join, 2026-10-26). The finder, an AI tool, was writing the reference semantics for the sort part of the unit's bounded-exhaustive harness and noticed that the count of "children" in ToParentDocValues ignores the childFilter iterator it is given. A public-API JUnit on the stock jar confirmed it.
  • Afterwards the harness, run with the javadoc's filter-relative reading, reproduced it: 288 mismatches in 9,504 checks over every layout of 2 to 3 documents and the tested sort settings. With this behaviour modelled as implemented, it found nothing else in 617,760 checks over every layout of 2 to 5 documents. Both runs skip STRING with MAX child selection and childMissingValue=STRING_LAST, the combination of the related bug LFV-223. So the signal came from code reading, not from the exhaustive run.
  • An independent reviewer from another model family (OpenAI GPT-5.6) then reproduced it but called it intended, and the finder re-filed it as a documentation question; an independent reproducer found the valued case, and further verifiers and an adjudication confirmed it as a bug.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 65 tool queries over GitHub, commits, downstream code, four mailing lists, the JIRA archive and the web, the files of all 413 open PRs, and the introducing PR #15727 and its issue #15548 read in full. Caveat: #15727's own selector test asserts the behaviour for excluded documents without a value.

Searches4 searches

The searches found no issue, PR, commit, list thread or JIRA entry about documents outside childFilter counting as children with missing values, or about the 10.4 to 10.5 sort-order change, and no open PR that changes the count. Because TestBlockJoinSelector.testSortedSelector asserts today's behaviour for excluded documents without a value (as expected behaviour, never as a defect), the novelty claim rests on the valued case. GitHub issue search does not index PR review comments (those of #15727 were read by hand), and private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2630 queries from the scripted prior-report search (ToParentBlockJoinSortField, childFilter, missing value, ToParentDocValues, BlockJoinSelector, 10.5 sort change, ...) plus direct searches such as "block join" sort and "nested sort" since 2026only the introducing #15727 / #15548 and older unrelated items
Threads read in full2026-10-26#15548 (body, 3 comments), #15727 (body, 2 comments, 14 reviews, 19 review comments), #16887, LUCENE-7871, LUCENE-6554, a 2017 dev@ and a 2016 java-user@ threadnone mentions documents outside childFilter; #16887 is a different component and cause
Open PRs and commits2026-10-26the changed files of all 413 open apache/lucene PRs; 3 commit queries; the commits of the three files on main, branch_10x and branch_10_6#14475, #14775 and #14996 touch these files, none changes the count; the last change on every branch is #15727
Mailing lists, JIRA archive, downstream code, web2026-10-2620 queries over dev@, java-user@, issues@ and commits@; 4 LUCENE JIRA queries by the tool plus 5 LUCENE and 4 SOLR JQL queries run directly; 2 downstream-repository queries and code searches in Elasticsearch, OpenSearch and Solr; 6 web queriesno report; Solr's childfield sort is the only downstream user found

History

Introduced by #15727 (merged 2026-03-03), a fix for children without values, and first released in 10.5.0. 10.5.0, 10.5.1, 10.5.2 and main fail; 9.12.3, 10.0.0, 10.3.2 and 10.4.0 sort correctly, and reverting #15727's three classes fixes it. Not fixed upstream.

Versions tested7 rows

Introduced by #15727, "Fix incorrect handling of missing values in ToParentBlockJoinSortField" (gmarouli; merged by romseygeek on 2026-03-03; milestone 10.5.0; closes #15548), as 167503f6d84d on main and 0915f9cac549 on branch_10x (the same patch by git patch-id --stable); the branch_10x commit is in the 10.5.0, 10.5.1, 10.5.2 and 10.6.0 tags. git blame attributes the count lines to it; before it, ToParentDocValues aggregated only over childFilter documents with a value. For these three classes, 10.4.0 is byte-identical to the backport's parent, 10.5.0 and 10.5.2 to the backport, and main to 167503f6d84d. branch_10_6 and branch_10x (heads of 2026-10-24) carry the same lines (read, not run).

Versions and branches executed, with results
Version or commitVerdictResult
9.12.3, 10.0.0, 10.3.2, 10.4.0 not affectedthe STRING-only test passes 2 of 2 (valued and field-less sibling); the numeric constructor does not exist
10.5.0 first affectedSTRING-only test 2 of 2 fail; numeric test fails; this doc's test 2 of 2 fail (assertions off)
10.5.1 affectedSTRING-only test 2 of 2 fail; numeric test fails
10.5.2 affectedSTRING-only test 2 of 2 fail; numeric test fails; this doc's test 2 of 2 fail
main ea57360912ed affectedthis doc's test 2 of 2 fail through gradle and the harness, assertions on and off
10.5.0 and main with #15727's three classes reverted commit levelthe STRING-only test passes 2 of 2 (single-class shadow runs, not full builds)
main + the fix not affectedthe test passes; the whole join module passes (106 tests) with the fixture change

Independent checks

An independent reproducer, an OpenAI GPT-5.6 reviewer arguing 'works as designed' (reading only), a devil's advocate and an adjudication say real. An earlier GPT-5.6 refute review reproduced it but called the field-less variant intended; the valued case answers that. This doc's test was re-run on main, 10.5.0 and 10.5.2, and with the fix.

Checks and verdicts468 words

Most checks ran on main ea57360912ed with seed DEADBEEF, assertions on and off, unless noted; the refute review and the security probe also used other fixed seeds, recorded in their result files.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidate, then filed as a documentation questionIts field-less-sibling JUnit fails 2 of 2 on main and 10.5.2, the STRING test passes on 10.4.0, and its fix sketch passes; it accepted the refute review's 'intended' reading.
Refute review (OpenAI GPT-5.6) not real: intended (high)Reproduced the behaviour with its own test and agreed on the root cause, but called the block-wide count deliberate, citing testSortedSelector and package-info. Its tests had only excluded documents without the field.
Independent reproducer realOwn tests: STRING 5 of 7 and numeric 4 of 6 fail, identical with assertions on and off, controls pass; found the valued case, three-level blocks and the upstream fixture sorted ascending; 10.4.0 and 9.12.3 pass; its fix sketch passes everything except the one testSortedSelector expectation.
'works as designed' reviewer (OpenAI GPT-5.6) real (medium); intended argument strongReading only (it could not run Java): the test encodes the block-wide count, but the javadoc, the documented mixed-type blocks and BlockJoinSelector's other path honour only the supplied children. A separate run ran the finder's test (2 of 2 fail, assertions off), a valued-sibling test (fails; passes with the fix) and the upstream fixture ascending (fails on main and 10.5.0, passes on 10.4.0).
Devil's advocate realFive 'intended' arguments examined, none explains the valued case; its valued-sibling test fails 2 of 4 on main and 10.5.2 (the 2 controls pass), passes on 10.4.0, and the fix passes 5 of 5.
Adjudication confirmed (high), silent wrong resultsRe-classified from documentation back to bug; its own valued-sibling test (this doc's test, renamed) fails 2 of 2 on main with assertions on and off and on 10.5.2.
History, prior reports, security regression in 10.5.0; new; out of scope of SECURITY.mdRelease sweep and shadow revert of #15727; 65-query prior-report search with nothing found; a security probe on main and 10.5.2 showing only the sort order changes.
This doc re-run and fix validatedThe test fails 2 of 2 on main (gradle and harness, assertions on and off), 10.5.2 and 10.5.0; the fix passes it, the verifiers' tests, the security probe and the whole join module.
Fact checks of this doc bug real; wording correctedAn independent fact check re-ran the test (main with assertions on and off, 10.5.0, 10.5.1, 10.5.2), the gradle command in a fresh clone, the STRING-only test on 10.4.0 and 9.12.3, and the fix with the whole join module; a second reviewer from another model family (OpenAI GPT-5.6, reading only) and a diagram check followed.

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

Finding record

LFV-220, snapshot of 2026-11-06

Registry title
ToParentBlockJoinSortField treats a parent as having a missing child value whenever its block holds any document outside childFilter, even if every document has the sort field (regression in 10.5.0)
Area
join / ToParentBlockJoinSortField nested sort (ToParentDocValues missing-child count, #15727)
Priority
Medium, rank 46: A 10.5.0 regression makes ToParentBlockJoinSortField sort a parent as missing whenever its block holds any document outside childFilter, silently reordering parents in the documented multi-child-type layout, while its only downstream user (Solr) has not upgraded yet.
Impact class
silent wrong results
Impact note
Applications on 10.5.0-10.5.2, 10.6.0 (by reading) or main that sort block-join parents with ToParentBlockJoinSortField and a childFilter that does not select every non-parent document of the block (several child types in one block, a layout the join package documents, or grandchildren in multi-level blocks) get a silently wrong parent order with the default codec and searcher, -ea or -da, and no scale: an affected parent's FieldDoc value becomes null (STRING) or childMissingValue (numeric) although every child selected by childFilter has a value, under the default ascending STRING sort (MIN, missing first) and under a numeric sort whose non-null childMissingValue wins the min/max (with STRING, MAX child selection and STRING_LAST the sibling bug's IndexOutOfBoundsException fires instead), while STRING with MAX and missing-first, STRING with MIN and STRING_LAST, and numeric sorts with childMissingValue=null are unaffected; the set of hits, the counts and the index are unchanged, and 10.4.0 sorts these cases correctly.
Summary
You index blocks with two kinds of non-parent documents, for example P1 = [type:a s=y n=40, type:b s=x n=7, parent P1] and P2 = [type:a s=d n=10, parent P2], and sort the parents of a ToParentBlockJoinQuery with new ToParentBlockJoinSortField("s", STRING, false, parents, childFilter type:a), expecting 'P2=d P1=y', which is what Lucene 10.4.0 returns. Instead 10.5.0 and later return 'P1=null P2=d', sorting P1 as if it had a child without the field although every document in both blocks has it, and a LONG sort with childMissingValue=0 gives 'P1=0 P2=10' instead of 'P2=10 P1=40'. The cause is that ToParentDocValues decides whether some child lacks a value by comparing the number of childFilter documents that have a value with the number of all documents in the block, so any document outside childFilter, with or without the field, forces the missing value.
Introduced
167503f6d84d8e43db4ec6ae3baf17337290393f on main and its branch_10x backport 0915f9cac54917269e58f952192b5b62a768ee47 (the same patch, git patch-id --stable 17a14d47ca74; the backport is the commit in the release tags): apache/lucene#15727 'Fix incorrect handling of missing values in ToParentBlockJoinSortField' by gmarouli, merged by romseygeek on 2026-03-03, milestone 10.5.0, closes #15548 [read: git blame of ToParentDocValues.java:265-267, 311 and 333 and git log -S 'totalChildren' / -S 'hasChildWithMissingValue' on the join module return only these two commits; the three classes #15727 changed are byte-identical between 10.4.0 and 0915f9cac54^ and between 10.5.0/10.5.2 and 0915f9cac54; exec: a shadow revert of those three classes to 0915f9cac54^ turns the 10.5.0 and main failures into passes]; before #15727 ToParentDocValues aggregated only over childFilter documents with a value, so an excluded document could not affect the parent (2026-03-03)
First affected version
10.5.0 [exec] (10.4.0 passes [exec])
Affected versions
10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading, see the branch_10_6 read below) and main ea57360912ed (11.0.0-SNAPSHOT) [exec: the follow-up's portable STRING test fails on all four with -ea and on main also with -da; the LONG childMissingValue test fails on 10.5.0, 10.5.1, 10.5.2 and main; the adjudication's and the intended-behaviour check's repros fail on main with -ea and -da and on 10.5.2]; not affected: 9.12.3, 10.0.0, 10.3.2 and 10.4.0 [exec, STRING test]; the childMissingValue constructors were added by #15727, so the numeric variant does not compile on 10.4.0 [exec]; branch_10_6 and branch_10x (heads 2026-10-24) carry the same lines [read, not executed]; not fixed upstream: the last change to ToParentDocValues, ToParentBlockJoinSortField and BlockJoinSelector on main, branch_10x and branch_10_6 is #15727 itself [read 2026-10-26]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (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; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new: a scripted prior-report search with 65 read-only queries (30 GitHub issue/PR queries on apache/lucene, 3 commit queries, 2 downstream-repository queries, 20 queries over the dev@, java-user@, issues@ and commits@ lists, 4 JIRA-archive queries, 6 web queries), 0 errors, plus direct GitHub issue and code searches, a changed-file scan of all 413 open apache/lucene PRs (#14475, #14775 and #14996 touch these files, none changes the child count), the latest commits of the three files on main, branch_10x and branch_10_6, local git history, direct list and JIRA queries, and full reads of #15548, #15727 (body, 2 comments, 14 reviews, 19 review comments), #16887, LUCENE-7871, LUCENE-6554 and two list threads; 21 hits read, none of them this bug); origin: #15727 (closes #15548), whose TestBlockJoinSelector.testSortedSelector (lines 176-179) asserts the block-wide count for out-of-filter documents WITHOUT a value (parents 5 and 15) but never discusses it, and no fixture has an out-of-filter document with a value; not fixed upstream (no change to the three files since #15727 on any branch); #16887 (TopFieldCollector with document blocks; closed 2026-10-30, fixed by #16947 (romseygeek; merged 2026-10-30 as f6e64aa0db3 on main and e5c8f72f00f on branch_11x; not on branch_10_6 or in 10.6.0)) is a different component and root cause
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26), seed DEADBEEF, assertions on and off, through gradle and through a standalone JUnit harness; this doc's test also on the Maven Central jars of 10.5.0 (assertions off) and 10.5.2 (on), and in the fact check on 10.5.0, 10.5.1 and 10.5.2 (on); a STRING-only variant of the same layout on 9.12.3, 10.0.0, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main, and with #15727's three classes reverted over 10.5.0 and main; the fix in a scratch worktree at ea57360912ed (the test, the two closest suites, the whole join module with assertions on and off, :lucene:join:check -x test), removed afterwards; the verifiers' tests and the security probe with the fix shadowed in.

Lucene and upstream sources

Finding record
  • lucene/join/src/java/org/apache/lucene/search/join/ToParentDocValues.java:96-103 (SortedDVs.ordValue min/max with missingOrd), :187-195 (NumDV.longValue min/max with childMissingValue), :213-218 (childWithValues = intersect(children, values)), :264-267 (nextDoc totalChildren), :310-311 and :333 (advanceExact totalChildren)
  • lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:46 (@lucene.experimental), :63, :83, :110, :147 (@param childFilter 'Filter that defines which child documents participates in sorting.'), :106-108, :143-145 (@param childMissingValue 'The missing value for child documents that lack the sort field.')
  • lucene/join/src/java/org/apache/lucene/search/join/package-info.java:25-31 (blocks of normalized content; parent documents are the last of each block, child documents all others)
  • lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSelector.java:113 and :176-179 (testSortedSelector expects ord -1 for parents 5 and 15); lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSorting.java:45 (testNestedSorting); lucene/CHANGES.txt:1154-1156 (GITHUB#15727 entry)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/ToParentDocValues.java:55, 64-78, 96-103, 156-169, 187-195, 216, 265-267, 310-311, 333
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:46, 106-108, 143-145, 220-229, 243-257
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/package-info.java:30-31
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java:309-310, LongComparator.java:79-80 (advanceExact path); ToParentDocValues.java:316-318, 326-328 (early returns)
Who is affected
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/package-info.java:26-31
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/ToParentDocValues.java:96-103, 187-195 (which settings the flag changes)
  • Lucene main ea57360912ed: lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSorting.java:45, 284-296
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:108, 145, 221-222, 229 (child-level MIN/MAX; STRING childMissingValue null)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:46, 63, 83, 106-110, 143-147
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/BlockJoinSelector.java:34, 51-89, 91-95, 116-120
  • Lucene main ea57360912ed: lucene/CHANGES.txt:1154-1156
  • Lucene main ea57360912ed: lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSorting.java:45, 284-296; TestBlockJoinSelector.java:113, 176-179
  • Lucene main ea57360912ed: lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSelector.java:113-140 (fixture: docs 18 and 19 have values; 18 is the last parent)
Suggested fix
  • Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:229, 256, 283, 313, 352; BlockJoinSelector.java:34, 135-137

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

Repro files

About the diagrams

The summary card (bug.png) shows the two blocks, the four steps from the min selection to the missing value, expected against actual, the affected settings and the javadoc contracts. Every number comes from a probe run on ea57360912ed.

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.