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.
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.- 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.
- Search the parents of the type:a children, sorted by
new ToParentBlockJoinSortField("s", STRING, false, parents, childFilter)withchildFilter= type:a: each parent sorts by the smallestsof its type:a children. - You expect
P2=d P1=y, which is what 10.4.0 returns: the type:b document is outsidechildFilter, so it takes no part. - 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. - Numeric sorts too: LONG with
childMissingValue=0givesP1=0 P2=10instead ofP2=10 P1=40. No error; the hits and the hit count do not change.
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.
- 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 offieldamong the parent's children selected bychildFilter. Two constructors added by #15727 also take achildMissingValue, "The missing value for child documents that lack the sort field".
- Each comparator passes
toIter(childFilter.getBitSet(context)), aBitSetIterator, toBlockJoinSelector.wrap, which builds aToParentDocValues(ToParentBlockJoinSortField.java:220-229for STRING, the numeric types alike). ToParentDocValuesintersects the children with the doc values (childWithValues,ToParentDocValues.java:216) and takes the min or max over those documents only, counting them. So far onlychildFilterdocuments take part, which is correct.- It then sets
hasChildWithMissingValue = childrenWithValuesCount < totalChildrenwithtotalChildren = docID - prevParentDocID - 1, every non-parent document of the block (nextDoc:265-267,advanceExact:310-311and:333). For P1: 1 < 2, sotrue, though no child lacks the field.advanceExact, the path the top-hits comparators use (TermOrdValComparator.java:309-310,LongComparator.java:79-80), returnsfalsebefore this comparison when nochildFilterdocument of the block has a value (ToParentDocValues.java:316-318,:326-328), so only parents with at least one such document are flagged. - The flag replaces the value. STRING:
ordValue()returnsMath.min(ord, missingOrd)for MIN andMath.max(ord, missingOrd)for MAX (:96-103), withmissingOrd-1, orInteger.MAX_VALUEforchildMissingValue = STRING_LAST. For P1,Math.min(2, -1) = -1: no value,FieldDocvaluenull, sorted first. Numeric:longValue()returnsMath.min/max(value, childMissingValue)whenchildMissingValueis 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.
One segment, default codec, CheckJoinIndex passes; query ToParentBlockJoinQuery(type:a, parents, ScoreMode.None).
| block | documents | in childFilter (type:a) |
|---|---|---|
| P1 | doc 0 type:a s=y n=40, doc 1 type:b s=x n=7, doc 2 parent | doc 0 only |
| P2 | doc 3 type:a s=d n=10, doc 4 parent | doc 3 |
| sort | expected | 10.5.0, 10.5.2, main |
|---|---|---|
| STRING ascending, 5-argument constructor | P2=d P1=y (10.4.0 returns this) | P1=null P2=d |
LONG ascending, childMissingValue=0 | P2=10 P1=40 | P1=0 P2=10 |
P1=0 is neither doc 0's 40 nor doc 1's 7: the missing value replaced a real one.
- An excluded sibling without the field:
A=null B=cinstead ofB=c A=z. - Three-level blocks with
childFilteron the child level: every parent that has a grandchild sorts as missing (A=null B=null C=minstead ofB=c C=m A=z; run with grandchildren that lack the field). - The index of upstream's
TestBlockJoinSorting.testNestedSorting(every child hasfield2; rebuilt by verifiers),childFilterfilter_1:T, sorted ascending:d=null i=null a=a b=c c=e g=kinstead ofa=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, whereMath.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
ToParentBlockJoinSortFieldand achildFilterthat 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 owntestNestedSortingfixture uses; or multi-level blocks withchildFilteron one level. - Affected settings. MIN and MAX are the child-level selection (
reverse, orreverseChildrenin 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 explicitchildMissingValue = null, which also acts as missing-first (:229) although the javadoc says null skips children without a value (:108,:145); numeric types with achildMissingValuethat wins the min/max (for example 0 ascending,Long.MAX_VALUEdescending); STRING descending withchildMissingValue = STRING_LAST, where a related bug (LFV-223) throwsIndexOutOfBoundsExceptioninstead. - Not affected. STRING descending with missing-first and STRING ascending with
STRING_LAST(the missing marker cannot win), and numeric sorts withchildMissingValue = null. - Default or opt-in. The class is opt-in and
@lucene.experimental, and a selectivechildFilteris the application's choice. Nothing else is needed: default codec, config and searcher, assertions on or off;CheckJoinIndexaccepts the index. - Scale. None: 5 documents in one segment. Deterministic: every returned parent whose block holds a
childFilterdocument with a value and a document outsidechildFilteris flagged, and under the affected settings gets the missing value, on every query. - What you see. No error. A wrong parent order and wrong
FieldDocsort values (nullfor STRING,childMissingValuefor numeric types). The hits,totalHitsand 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, sochildfield(<string field>) ascwould be affected, but Solr main pins Lucene 10.4.0. Elasticsearch and OpenSearch do not use this class. That says nothing about private code.
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.
/*
* 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());
}
}
}
}
# 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-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- The javadoc:
childFilter"defines which child documents participates in sorting";childMissingValueis 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). BlockJoinSelectoragrees. Itswrapmethods "select one value per parent among its {@code children}" (BlockJoinSelector.java:91-95,:116-120), wherechildrenis the iterator the caller passes; itsBitspath 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 outsidechildFilter; #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 selectivechildFilteron mixed blocks and expects the excluded siblings' values to be ignored, but checks only the descending order, whereMath.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-179expect 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
childMissingValueconstructors 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.
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.
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
nextSetBitcall perchildFilterdocument 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 visitschildFilterdocuments 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
BitSetIteratorkeep today's block-wide count. All in-tree callers passtoIter(children)(ToParentBlockJoinSortField.java:229,:256,:283,:313,:352), andBlockJoinSelectoris@lucene.internal; option D removes the fallback. - Safe bounds. The loop keeps
nextSetBit's start below its end (astart == endcall 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
testSortedSelectorneeds its fixture changed. - Side effect. It removes the widened trigger of the related
IndexOutOfBoundsException(LFV-223) when the only 'missing' document is outsidechildFilter, not that bug itself.
What the checks validated
- Stock: the test fails 2 of 2 with assertions on and off; the modified
TestBlockJoinSelectorpasses 4 of 4. - Fix alone: the test and
TestBlockJoinSortingpass;testSortedSelectorfails 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:tidyleft the fix unchanged;:lucene:join:check -x testpasses (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).
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.
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.
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.
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.
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
ToParentDocValuesignores thechildFilteriterator 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | 30 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 2026 | only the introducing #15727 / #15548 and older unrelated items |
| Threads read in full | 2026-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@ thread | none mentions documents outside childFilter; #16887 is a different component and cause |
| Open PRs and commits | 2026-10-26 | the 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, web | 2026-10-26 | 20 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 queries | no report; Solr's childfield sort is the only downstream user found |
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).
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.3, 10.0.0, 10.3.2, 10.4.0 | not affected | the STRING-only test passes 2 of 2 (valued and field-less sibling); the numeric constructor does not exist |
| 10.5.0 | first affected | STRING-only test 2 of 2 fail; numeric test fails; this doc's test 2 of 2 fail (assertions off) |
| 10.5.1 | affected | STRING-only test 2 of 2 fail; numeric test fails |
| 10.5.2 | affected | STRING-only test 2 of 2 fail; numeric test fails; this doc's test 2 of 2 fail |
main ea57360912ed | affected | this 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 level | the STRING-only test passes 2 of 2 (single-class shadow runs, not full builds) |
| main + the fix | not affected | the test passes; the whole join module passes (106 tests) with the fixture change |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate, then filed as a documentation question | Its 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 | real | Own 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 strong | Reading 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 | real | Five '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 results | Re-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.md | Release 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 validated | The 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 corrected | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 551 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,355 words
- 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 atea57360912ed(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.
- 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, 333Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:46, 106-108, 143-145, 220-229, 243-257Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/package-info.java:30-31Lucene 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-31Lucene 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-296Lucene 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-147Lucene main ea57360912ed: lucene/join/src/java/org/apache/lucene/search/join/BlockJoinSelector.java:34, 51-89, 91-95, 116-120Lucene main ea57360912ed: lucene/CHANGES.txt:1154-1156Lucene main ea57360912ed: lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoinSorting.java:45, 284-296; TestBlockJoinSelector.java:113, 176-179Lucene 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
- TestBlockJoinSortFilteredOutSibling.java (JUnit test):
repro/LFV-220/src/org/apache/lucene/search/join/TestBlockJoinSortFilteredOutSibling.java(86 lines)
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.
assets/LFV-220/bug.png(551 KB)
