Lucene answers two questions for a query: which documents match (the scorer) and, for one document, did it match and where (Weight#matches). For this join the scorer applies the parent condition and the per-parent limit; matches() applies neither.
Weight#matches says yes for docs 0 to 3, because it asks the parent query and the child query about each document on its own. Select the still to open it at full size. Generated with AI, checked against the code.- You index two parent/child blocks: docs 0 "size small" and 1 "size large" are children of parent doc 2 "red shirt"; doc 3 "size small" is the child of parent doc 4 "blue shirt". The join asks for children with "size" whose parent has "red".
- The search is right: docs 0 and 1. You expect
Weight#matches, which highlighters andBooleanQueryuse to ask "does this one document match?", to say yes for docs 0 and 1 only. - Instead it says yes for docs 0, 1, 2 and 3: it asks the parent query and the child query about the same document, so the red parent (doc 2) and the blue parent's child (doc 3) count too. With a limit of 1 per parent the search returns only doc 0; the answer stays 0 to 3.
- A query that excludes the join breaks:
+text:size -joinfinds the right documents, butWeight#matchessays they do not match (doc 3 with no limit; docs 1 and 3 with a limit of 1). The default UnifiedHighlighter then returns doc 1 assize largeinstead of<b>size</b> large. - Lucene Monitor's
HighlightsMatchmatcher decides matches withWeight#matches, so for a stored "children of public parents" query it reports docs 0, 1, 2 and 3 where the plain matcher reports 0 and 1. This bug never changes search hits, counts or scores.
Users of the experimental ParentsChildrenBlockJoinQuery (10.4.0 on) who read its Matches beyond its own hits: the join as a MUST_NOT clause under the default UnifiedHighlighter, Lucene Monitor's HighlightsMatch matcher, or code that calls Weight#matches on other documents. For them it happens every time such a document is asked about; no special setting is needed.
Mechanism, terms and numbers690 words
ParentsChildrenBlockJoinQuery (new in 10.4.0, @lucene.experimental) returns the child documents that match childQuery and whose block parent matches parentQuery, at most childLimitPerParent per parent. Its scorer implements that join; its Weight#matches(context, doc) does not. It returns the union of parentWeight.matches(context, doc) and childWeight.matches(context, doc) for the same document and never checks the parent bit set, the document's own block parent or the limit. So matches() is non-null for documents the query does not match: a parent that matches parentQuery, a child that matches childQuery under a parent that does not match (or with no parent), and a child over the limit.
This bug does not change search hits, counts or scores (a separate advance() issue can, see the fix trade-offs), and the join's own hits get the right Matches. The damage shows wherever something asks Weight#matches about a document that is not a hit. As a MUST_NOT clause the spurious Matches make BooleanWeight#matches null for the enclosing query's real hits, so +text:size -join loses its Matches and the default UnifiedHighlighter its highlights; as a SHOULD clause it can satisfy minimumNumberShouldMatch on documents it does not match; Lucene Monitor's HighlightsMatch.MATCHER reports parents, children of non-matching parents, lone children and over-limit children as matches. The test framework's own CheckHits.checkMatches rejects +text:size -join and, on an index of two red-parent blocks, the join used alone. The method has been this way since the class was added (#14728); a block-local check in matches() (+30/-13, one method) fixes it.
- Blocks: a parent and its children are indexed together with
IndexWriter#addDocuments, children first, parent last. ABitSetProducer(hereQueryBitSetProducer(type:parent)) marks the parents. ParentsChildrenBlockJoinQuery(parentFilter, parentQuery, childQuery, childLimitPerParent)(10.4.0, experimental): "returns the matching child documents for matching parent documents indexed together in the same block", at mostchildLimitPerParentper parent (class javadoc,ParentsChildrenBlockJoinQuery.java:43-51on maina9779ef3c193).Weight#matches(context, doc): for one document, returnsMatches(which fields and positions matched) or null "if the document does not match the parent query", the parent query being the Weight's own query (Weight.java:61,:68-69).BooleanWeight#matches, the UnifiedHighlighter (on by default), Lucene Monitor'sHighlightsMatchand the test framework'sCheckHitsrely on it.
- The join lives in the scorer.
ParentsChildrenBlockJoinScorer(:265-452) walks the parent and child iterators together:nextDoc()(:314-347) counts the children returned for the current parent (childDocCountagainstchildLimitPerParent) andalignParentAndChildIterator()(:384-404) keeps the child inside the block of a matching parent, using the parent bit set. Parents are never returned. matches()skips all of it.ParentsChildrenBlockJoinWeight#matches(:248-262) asksparentWeight.matches(context, doc)andchildWeight.matches(context, doc)about the same doc id, returns null only when both are null ("Neither matches"), and otherwise returnsMatchesUtils.fromSubMatchesof the non-null ones ("Combine non-null matches"). Nothing looks at the parent bit set, the doc's block parent or the limit.- What the union does. For a valid
parentQuery(it matches only parents; the scorer throws when it lands on aparentQuerymatch that is not a parent,validateParentDoc,:425-429)parentWeight.matchesis null on every child, so on the join's own hits the union is just the child's Matches, which is right. Its only effect is "match" for non-hits: a parent that matchesparentQuery(doc 2), a child that matcheschildQueryunder a parent that does not match or with no parent in the segment (doc 3), a child over the limit (doc 1 with limit 1). - Who believes it.
BooleanWeight#matches(BooleanWeight.java:134-164) returns null for a document when a prohibited clause'smatches()is non-null (:142-146) and counts every non-null SHOULD clause towardminimumNumberShouldMatch(:153-162). The UnifiedHighlighter usesWeight#matchesby default (UnifiedHighlighter.java:110,:518-527) and collects no offsets when it is null (FieldOffsetStrategy.java:143-150). Monitor'sHighlightsMatch.MATCHERcallsmatches()for every document of the batch and records a match whenever it is non-null (HighlightsMatch.java:44-62). - The sibling does it right.
ToParentBlockJoinQuery's weight advances its own scorer to the document and returnsMATCH_WITH_NO_TERMS(ToParentBlockJoinQuery.java:200-219): "The default implementation would delegate to the joinQuery's Weight, which matches on children. We need to match on the parent instead".
Exposure and likelihood363 words
- Highlighting. With the join as a MUST_NOT clause, the enclosing query's real hits get null Matches, so the default UnifiedHighlighter returns them unhighlighted (
size largeinstead of<b>size</b> large; thewithWeightMatches(false)control highlights) [executed on main, 10.5.2 and 10.4.0]. As a SHOULD clause underminimumNumberShouldMatchthe join can be counted on documents it does not match [executed through Monitor]. - Lucene Monitor.
HighlightsMatch.MATCHERdiffers fromQueryMatch.SIMPLE_MATCHERin all 7 probe cases. Four use the stored join "children withbody:sizeofacl:publicparents": alone, withchildLimitPerParent1, and for one-document batches of a lone parent or a lone child. Three putbody:sizenext to a join whosechildQueryistype:child: ofacl:publicparents as a FILTER clause and as a SHOULD clause underminimumNumberShouldMatch2, ofacl:restrictedparents as a MUST_NOT clause (true matches dropped) [executed on main, 10.5.2 and 10.4.0]. - Other
Weight#matchescallers. Code that asks about documents not known to be hits, for example to label which named clause matched (NamedMatches), gets the same wrong answer [read, not executed]. - Settings. No special setting is needed: a plain
main()probe with the defaultIndexWriterConfig, the default codec and a plainIndexSearchergives the sameWeight#matchesanswers on main (assertions on and off), 10.4.0 and 10.5.2. The JUnit repros run underLuceneTestCase, which picks a randomized test codec (Asserting(Lucene104)in the recorded runs). Default highlighter flags. The mechanism does not depend on index size, but a visible effect needs a document that matchesparentQueryorchildQuerybut not the join, and a caller that asksWeight#matchesabout it; the executed indexes have up to 5 documents. - Not hit. Search hits, counts and scores (this bug does not touch the scorer; a separate
advance()issue can return an over-limit hit in a conjunction, see the fix trade-offs); the Matches of the join's own hits; the join alone or as a required clause, highlighted on its hits (no visible symptom, although on some indexesCheckHitsrejects the join alone). - Reach. The class is
@lucene.experimental, new in 10.4.0 and built programmatically (no query parser makes it); a code search found no use in Elasticsearch, OpenSearch or Solr. Likelihood: rare.
The test shows that ParentsChildrenBlockJoinQuery's Weight returns Matches for the parent document of a one-child block, which the join never returns.
/*
* 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.StringField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreMode;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.Weight;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestParentsChildrenMatchesParentDoc extends LuceneTestCase {
public void testParentDocHasNoMatches() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document child = new Document();
child.add(new TextField("text", "size", Field.Store.NO));
Document parent = new Document();
parent.add(new StringField("type", "parent", Field.Store.NO));
parent.add(new TextField("text", "red", Field.Store.NO));
w.addDocuments(List.of(child, parent));
}
try (DirectoryReader r = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(r);
Query join =
new ParentsChildrenBlockJoinQuery(
new QueryBitSetProducer(new TermQuery(new Term("type", "parent"))),
new TermQuery(new Term("text", "red")),
new TermQuery(new Term("text", "size")));
Weight weight =
searcher.createWeight(searcher.rewrite(join), ScoreMode.COMPLETE_NO_SCORES, 1f);
// the only hit is child doc 0: expected null Matches for parent doc 1, actual parentQuery's
assertNull("Matches of parent doc 1", weight.matches(r.leaves().get(0), 1));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-508/, 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.TestParentsChildrenMatchesParentDoc
- Expected vs actual
- Expected
matches(leaf, 1)== null for parent doc 1, because the only hit is child doc 0. Actual: non-null (parentQuery text:red's Matches), with-eaand-da.
More files, environment, full output39 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
=> java.lang.AssertionError: Matches of parent doc 1 expected null, but was:<org.apache.lucene.search.MatchesUtils$3@4e390179>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotNull(Assert.java:756)
org.junit.Assert.assertNull(Assert.java:738)
org.apache.lucene.search.join.TestParentsChildrenMatchesParentDoc.testParentDocHasNoMatches(TestParentsChildrenMatchesParentDoc.java:59)Weight#matchesreturns null "if the document does not match"; this query's hits are children of matching parents, up to the limit.- Lucene's own
CheckHits.checkMatchesfails on+text:size -joinand, on an index of two red-parent blocks, on the join alone, whereToParentBlockJoinQuerypasses. - The sibling
ToParentBlockJoinQueryoverridesmatches()for exactly this reason: "We need to match on the parent instead".
The full argument248 words
- The contract.
Weight.java:68-69: "ReturnsMatchesfor a specific document, ornullif the document does not match the parent query", the parent query being the Weight's own query (@param query the parent query,:61). The class javadoc (ParentsChildrenBlockJoinQuery.java:43-51) defines the hits as matching children of matching parents, at mostchildLimitPerParentper parent. - Lucene's own oracle.
CheckHits.MatchesAsserter(CheckHits.java:646-688, run byQueryUtils.check) requires non-null Matches on each hit and, for each hit after the first one collected whose previous document was not a hit, null on that previous document (the message names the hit). It fails on+text:size -join("Unexpected null Matches object in doc3") and, on an index of two red-parent blocks, on the join alone ("Unexpected non-null Matches object in non-matching doc2", about the red parent doc 1), whileToParentBlockJoinQuerypasses on that index [executed]. - The sibling.
ToParentBlockJoinQuerygives up child terms and returnsMATCH_WITH_NO_TERMSfrom its own scorer rather than report Matches the join does not produce (LUCENE-8229, #9276;ToParentBlockJoinQuery.java:200-219). - The rule, stated by the committer who merged #14728. In #15469 (comment of 2026-01-13): "
Weight#scorerSupplier()andWeight#matches()are expected to be semantically the same". It was said aboutFilterWeightsubclasses in general and names no class. - Nothing accepts the union. No test, javadoc, CHANGES or MIGRATE entry describes it. The class's only test,
TestParentsChildrenBlockJoinQuery, never callsmatches(),CheckHitsorQueryUtils.check; #14728's conversation and review comments and the issue #14565 never discussmatches().
The devil's-advocate check180 words
The strongest case for "intended": the comment "Combine non-null matches" (:257) shows the union was written on purpose; the class and Weight#matches are @lucene.experimental; the union has a use, since with the default highlighter a join query also highlights the non-hit parent (<b>red</b> shirt) and keeps highlights on join hits that a correct matches() loses on the highlighter's one-document reader; and a correct matches() costs more and must not call this Weight's own scorerSupplier() twice per leaf (LFV-167).
Why it fails: the union is taken on the same doc id, so it never adds the parent's terms to a child hit; its only effect is answering "match" for non-hits, against the explicit null-for-non-match contract that BooleanWeight, Monitor and CheckHits depend on. Highlighting a parent the query does not match is not something the query promises, and Lucene already chose correctness over extra highlights in the sibling. "Experimental" allows API changes, not a method that contradicts its own javadoc. Every reviewer of the claim, including two from another model family, rated the intended-behaviour case weak.
Recommended Check the join for the document's own block
In matches(), check the join for the document's own block: null for parents, for children whose block parent does not match parentQuery and for children past the first childLimitPerParent matching ones; otherwise the child's Matches (+30/-13, one method). The repro, the join module with the hunt's tests (115 tests) and the highlighter and Monitor tests pass.
All options and trade-offs3 options · 1,119 words
One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); the finder's alternative was validated in the hunt and re-run here with its class shadowed; the third option was not run.
Replace the body of ParentsChildrenBlockJoinWeight#matches (+30/-13, also removing the three imports the union used). It mirrors the scorer's definition of a hit (the block parent matches parentQuery, the child matches childQuery and is among the first childLimitPerParent matching children of its block) and returns the child's own Matches for hits, so hits keep exactly the Matches they get today. It builds the parent and child scorers directly, as ToParentBlockJoinQuery#matches builds its own, so it does not use up this Weight's single scorerSupplier() call per leaf (LFV-167). Work per call: one parentFilter.getBitSet call (cached per segment by QueryBitSetProducer; a custom BitSetProducer need not cache), two fresh sub-scorers, one advance on each, a walk over the matching children of one block and, for a hit, childWeight.matches. Not benchmarked.
final BitSet parentBits = parentFilter.getBitSet(context);
if (parentBits == null || parentBits.get(doc)) {
return null; // parent documents are never hits
}
final int parentDoc = parentBits.nextSetBit(doc);
if (parentDoc == DocIdSetIterator.NO_MORE_DOCS) {
return null; // no parent after this doc
}
final Scorer parentScorer = parentWeight.scorer(context);
if (parentScorer == null || parentScorer.iterator().advance(parentDoc) != parentDoc) {
return null; // the block's parent does not match parentQuery
}
final Scorer childScorer = childWeight.scorer(context);
if (childScorer == null) {
return null;
}
final int firstChild = parentBits.prevSetBit(parentDoc - 1) + 1;
final DocIdSetIterator childIt = childScorer.iterator();
int matchingChildren = 0;
for (int child = childIt.advance(firstChild); child <= doc; child = childIt.nextDoc()) {
if (++matchingChildren > childLimitPerParent) {
return null; // over the per-parent limit
}
if (child == doc) {
return childWeight.matches(context, doc);
}
}
return null; // doc does not match childQueryTrade-offs
- Known gap, a separate defect. In the hunt's bounded-exhaustive harness (every block layout of up to 4 documents), 40 of 108,336 checks of
+text:a +joinstill differ with the fix, all withchildLimitPerParent1 or 2: the conjunction moves the join withadvance(), which does not count the block's earlier children, so the search itself returns a child thatnextDoc()order puts over the limit, and the fixedmatches()says no. That lives in the scorer'sadvance()(ParentsChildrenBlockJoinQuery.java:350-382) and is not in the registry. - Highlighting interplay. With any correct
matches(), join hits whoseparentQueryuses the highlighted field lose highlights that today's union produces by accident. The join is an opaque leaf to the highlighter (ParentsChildrenBlockJoinQuery.java:116-117), so a postings offset source falls back to analysis (UnifiedHighlighter.java:1253-1272); with analysis (and term vectors) the default UnifiedHighlighter evaluatesWeight#matcheson a reader whose highlighted field holds only the hit document, where the hit's parent cannot match. ThePOSTINGS_WITH_TERM_VECTORSsource uses a different reader and was not tested. Hunt harness, highlighter oracle, every block layout of up to 3 documents on one segment, two highlighted fields: all 294 such checks per field pass on stock main and all 294 per field fail with this fix; the finder's fix gave the same, and 144 of 144 per field on two segments. That highlighter defect is registered separately as LFV-505 (novelty "known": a partial downstream overlap with elastic/elasticsearch#96068), established for the tested block joins whose condition uses the highlighted field. - Nested joins [read, not executed]. It calls
parentWeight.scorer(context)andchildWeight.scorer(context)on every call. If a sub-query is or contains aParentsChildrenBlockJoinQuery, that inner Weight's one-scorerSupplier()-per-leaf guard (:191-197, LFV-167) throws on the second outermatches()call for a leaf, or on the first one after the inner Weight already scored it; today's union calls only the sub-weights'matches(). Option B has the same exposure. - No parent-query validation [read]. Like today's
matches(), it does not repeat the scorer'svalidateParentDoccheck (:425-429): with an invalidparentQuerythat also matches children, the scorer may throw whilematches()just answers for the block's real parent. - Add a
CheckHits.checkMatches(orQueryUtils.check) call toTestParentsChildrenBlockJoinQuery; no test of the class callsmatches()today.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF: the repro passes 4 of 4 with assertions on and off, together withTestParentsChildrenBlockJoinQuery(4 + 8 = 12 tests). In an earlier scratch worktree with the same fix, the wholelucene/joinmodule with the finder's and the verifiers' tests added (11 tests; this doc's repro was not in that run): 115 tests, 3 skipped, 0 failures.checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass (run after the added tests were removed, so on the production edit only). - With the fixed class shadowed into the stock
a9779ef3c193jars: the highlighter test 1 of 1, the Monitor probe 7 of 7, the finder's 3, the independent reproducer's 6 and the devil's advocate's 2 tests pass. - Hunt harness, every block layout of up to 4 documents: stock 44,808 of 108,336 checks fail for the join alone on one segment; with the fix 0 for the join alone,
*:* -joinandmsm=2(join, text:b)on one and two segments, and the 40advance()cases above.
Re-run the join's own scorer from the start of the segment (the finder's fix) (validated by the checks)
Build a ParentsChildrenBlockJoinScorer from fresh parent and child scorers and call nextDoc() until it reaches doc; return the child's Matches only on a hit.
Trade-offs
Every call walks the segment up to doc, so a caller that asks about every document (Monitor's HighlightsMatch.MATCHER) does quadratic work per batch (not measured). It decides membership with nextDoc(), so it shares the advance() gap of option A, and it calls the sub-weights' scorer() like option A, with the same nested-join exposure [read]. Its diff leaves ArrayList, List and MatchesUtils imported but unused.
What the checks validated
In the hunt on ea57360912ed: the finder's, the highlighter, the reproducer's and the Monitor tests pass, and the join-module run gives the same result as without it (104 tests found, 101 successful, 3 aborted because the nightly test group was off, 0 failed). On a9779ef3c193, shadowed: the finder's tests 3 of 3 and the Monitor probe 7 of 7 pass.
Call scorer(context) and advance(doc), as the sibling does.
Trade-offs
Not viable as is: any call after this Weight has already supplied a scorer for the leaf (the second matches() call of a fresh Weight, or the first one after it scored the leaf) trips the one-scorerSupplier()-per-leaf guard (ParentsChildrenBlockJoinQuery.java:191-197, LFV-167), and advance() ignores the limit (the gap above). Not run.
Code that decides matches with Weight#matches, such as Lucene Monitor's HighlightsMatch.MATCHER, reports documents that the join's parent condition or child limit excludes, which matters downstream only where such a match routes or delivers documents; no downstream use of this query was found in Elasticsearch, OpenSearch or Solr.
A bounded-exhaustive differential test of Lucene's block joins on the real jar: every block layout of up to 5 documents, comparing Weight#matches with the hits of a fresh scorer. It flagged this join alone and in every wrapper query; a public-API JUnit then confirmed it. No model checker, solver or prover was involved.
The method in full213 words
Method: bounded-exhaustive translation validation on the stock jar (the semi-formal tier).
- First signal: a block-join hunt compared the real join jar with an independent brute-force join over every small index. Its
Weight.matchescheck reported 3,512, 3,460 and 3,456 mismatches forchildLimitPerParent1, 2 and unlimited at up to 4 documents (about 1.3 million each at up to 7). It was recorded as an unpromoted lead; a second hunt on explain/score coherence noted the same defect as a lead. - A later hunt on Matches coherence across module queries promoted it with its own harness: oracle M1,
Weight#matchesnon-null exactly for the documents in a fresh scorer's match set, over every block layout of up to 5 documents with every text from {"", a, b, "a b"}, on 1 and 2 segments, for the query alone and inside+a +Q,*:* -Qandmsm=2(Q, b). For example 446,600 of 1,091,376 checks failed for the join alone on one segment;ToParentBlockJoinQueryand the olderParentChildrenBlockJoinQueryhad 0. The finder then wrote a public-API JUnit on the stock main jar. - Found 2026-10-26 on main
ea57360912ed; then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a security screen and a credibility audit.
No prior report found (2026-10-26) in about 150 recorded queries over GitHub, the mailing lists, JIRA, Elasticsearch, OpenSearch, Solr and the web, plus the changed files of all 408 open PRs. Related only: #14728 (added the method), #15469 (the rule), LUCENE-8229 (the sibling's override).
Searches5 searches
Verdict new: 22 hits read, none the same bug. Keyword search cannot prove absence; GitHub search does not index review comments (#14728's were read directly), and private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; Elasticsearch, OpenSearch, Solr; the web | 2026-10-26 | the hunt's prior-report tool: two follow-up runs (51 and 24 queries) and the finder's run (38 queries) | no report; related only: #14728, #14565, #15041 / #15042, #14975 / #15435, #9276 (LUCENE-8229) |
| GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr; code search) | 2026-10-26 | 31 manual searches and API reads | only #14728 and #15041 name the class |
| #14728 and #15469 threads | 2026-10-26 | conversation, reviews and inline review comments read directly | #14728 never discusses matches(); #15469 states the scorerSupplier/matches rule for FilterWeight subclasses in general |
| open apache/lucene PRs and branches | 2026-10-26 | changed files of all 408 open PRs; the file's history on main, branch_10x, branch_10_6, branch_10_5 and branch_10_4 | no open PR touches the file; not fixed on any branch |
| the web | 2026-10-26 | 6 web searches | nothing about the class; an outside Go port copies matches() without comment; elastic/elasticsearch#96068 turns matches-mode highlighting off for nested and parent-join queries (predates the class) |
Present since the class was added by #14728 (1584c05b27ac, 2025-06-13; backport 61b32ff6d377) and first released in 10.4.0. Executed: 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main fail; 10.3.2 and 9.12.3 lack the class. Not fixed upstream.
Versions tested7 rows
Introduced by #14728 "[BlockJoin] Add ParentsChildrenBlockJoinQuery" (issue #14565; author @Jinny-Wang, merged by @msfroh): main squash merge 1584c05b27ac on 2025-06-13, branch_10x backport 61b32ff6d377 (committed 2025-09-04), first contained in releases/lucene/10.4.0. The CHANGES entry is under 10.4.0 New Features (lucene/CHANGES.txt:1336 on main). matches() was added in its current form with the class and has not changed: git log --follow lists only 1584c05b27a and bc601411a31 (#16519, a cost() signature), and git log -S 'parentWeight.matches(context, doc)' finds only 1584c05b27a.
10.3.x does not have it: the 10.3 branch split from branch_10x on 2025-08-30, before the backport. Upstream main a9779ef3c193, branch_10x and branch_10_6 have the same matches() (read-only, not executed on the branches).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's JUnit 4 of 4 fail (assertions on and off, gradle and runner); the finder's 3 of 3, the highlighter 1 of 1, Monitor 7 of 7, the reproducer's 6 of 6 and the join-alone oracle 1 of 2 fail |
main ea57360912ed | affected | the hunt's runs: finder 3 of 3 (assertions on and off), reproducer 6 of 6, highlighter 1 of 1, Monitor 7 of 7 |
| 10.5.2 | affected | this doc's JUnit 4 of 4; finder 3 of 3; reproducer 6 of 6 (on and off); highlighter 1 of 1; Monitor 7 of 7 |
| 10.5.0, 10.5.1 | affected | finder 3 of 3 |
| 10.4.0 | affected | first release with the class; this doc's JUnit 4 of 4; finder 3 of 3; reproducer 6 of 6; highlighter 1 of 1; Monitor 7 of 7 |
| 10.3.2 + only the 10.4.0 class | isolation | finder 3 of 3 fail with only ParentsChildrenBlockJoinQuery.java from 10.4.0 shadowed in |
| 10.3.2, 9.12.3 | no such class | the repro does not compile: the class does not exist |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing. Reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication and an audit confirmed it, and this doc re-ran everything on a9779ef3c193 and validated the fix.
Checks and verdicts340 words
Every executing check wrote or re-ran its own JUnit on stock main; every control passed. No executed review from the other model family was obtained: both ran reading only.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Harness oracle M1 fails for the join in all four wrappers; the JUnit fails 3 of 3 on main (on and off), 10.5.2 and 10.4.0; the highlighter JUnit 1 of 1; both pass with the finder's fix. |
| independent reproducer | real | Own 6-test JUnit fails 6 of 6 on main, 10.5.2 (on and off) and 10.4.0; every hit-set control passes; passes 6 of 6 with the fix. |
| cross-model reviews (refute, intended) | not real (low, procedural); real (medium) | An independent reviewer from another model family (OpenAI GPT-5.6) could only read code. The refute lens's "not real" rests on the missing own execution; the lenses agreed on the root cause, found the inputs legal and rated the intended argument weak; separate re-runs failed 3 of 3. |
| intended-behaviour devil's advocate | real | Built the strongest "intended" case; its own test shows the join alone fails CheckHits.checkMatches while ToParentBlockJoinQuery passes on the same index; a probe shows the union's only extra is a highlight on the non-hit parent. |
| adjudication | confirmed (high) | Re-ran the reproducer's test with a new seed (6 of 6 fail, 6 of 6 pass with the fix) and the devil's advocate's test with assertions off. |
| security screen | not security-sensitive | Ran the Monitor probe (7 of 7 differ; 7 of 7 agree with the fix). Re-evaluated on 2026-10-27 under the strict rule: out of scope of Lucene's SECURITY.md, a downstream-relevant correctness bug. |
| credibility audit | keep | Re-ran the finder's test with a new seed and assertions off, and the highlighter test with a new seed; agreed with the score. |
| this doc | reproduced, fix validated | Re-ran every hunt test on a9779ef3c193; wrote the 4-test repro (fails on main, 10.5.2 and 10.4.0); validated the block-local fix with gradle, the join module, lint and the bounded-exhaustive harness. |
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 405 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,416 words
- Registry title
- ParentsChildrenBlockJoinQuery's Weight#matches ignores the join and returns Matches for documents the query does not match (parent documents, children of non-matching parents, children over childLimitPerParent), so as a MUST_NOT clause it strips the Matches and highlights of real hits, and Lucene Monitor's HighlightsMatch matcher reports the wrong documents
- Area
- join / block join Weight#matches (ParentsChildrenBlockJoinQuery; consumers BooleanWeight#matches, UnifiedHighlighter, Monitor HighlightsMatch)
- Priority
- Low, rank 357: A Matches-only error in the experimental ParentsChildrenBlockJoinQuery (10.4+); hits, counts and scores are correct.
- Impact class
- silent wrong results
- Impact note
- Applications that use the experimental ParentsChildrenBlockJoinQuery (new in 10.4.0) and read Matches from it get non-null Matches for documents the join does not return, with the default codec, IndexWriterConfig and IndexSearcher, -ea and -da, at any index size: the join alone already fails the test framework's CheckHits.checkMatches oracle; as a MUST_NOT clause it makes BooleanWeight#matches return null for the enclosing query's real hits, so the default UnifiedHighlighter returns those hits without highlights; and Lucene Monitor's HighlightsMatch.MATCHER reports parent documents, children of non-matching parents, children without their parent and over-limit children as matches where QueryMatch.SIMPLE_MATCHER does not (and, with the join as a MUST_NOT clause, drops true matches); search hits, counts and scores are correct, and the join's own hits get correct Matches.
- Summary
- You index parent/child blocks, build a ParentsChildrenBlockJoinQuery (children that match childQuery and whose parent matches parentQuery, at most childLimitPerParent per parent) and ask its Weight for Matches, directly, inside a BooleanQuery, through the default UnifiedHighlighter or through Lucene Monitor, and you expect non-null Matches exactly for the join's hits, as Weight#matches documents ('or null if the document does not match the parent query'). Instead matches() returns the union of the parent query's and the child query's Matches on the same document without checking the join, so it is non-null for the parent document itself, for children of parents that do not match and for children over the limit (in the finder's test the only hit is doc 0, but docs 0, 1, 2 and 3 get Matches). Searches stay correct, but a query such as +text:size -join loses the Matches and highlights of its real hits, and Monitor's HighlightsMatch matcher reports documents the stored query does not match.
- Introduced
- 1584c05b27ac31fbccb0ab328bf9f8eb6a7de414 (main, squash merge of PR #14728 '[BlockJoin] Add ParentsChildrenBlockJoinQuery', issue #14565, author Jinny-Wang, merged by msfroh) and its branch_10x backport 61b32ff6d3772906b12b9e7f855b8607e9261371; matches() was added in its current union form together with the class and has never changed [read: git log --follow on the file lists only 1584c05b27ac and bc601411a31 (#16519, a cost() signature change); git blame of ParentsChildrenBlockJoinQuery.java:248-262 gives 1584c05b27ac on main and 61b32ff6d377 on 10.4.0 and 10.5.2; git log -S 'parentWeight.matches(context, doc)' finds only 1584c05b27a; exec: 10.3.2 and 9.12.3 lack the class (compile error), and 10.3.2 with only the 10.4.0 class shadowed in fails 3 of 3] (2025-06-13 (main squash merge); branch_10x backport committed 2025-09-04)
- First affected version
- 10.4.0 [exec] (the first release with the class; 10.3.2 and 9.12.3 do not have it and the repro does not compile there [exec]; the 10.3 branch split from branch_10x on 2025-08-30, before the backport [read])
- Affected versions
- 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) and main ea57360912ed [exec: the finder's JUnit fails 3 of 3 on each, with -ea and on main also with -da; the independent reproduction's JUnit fails 6 of 6 on main -ea and -da, 10.5.2 -ea and -da and 10.4.0 -ea; the intended-behaviour check's oracle test fails 1 of 2 on main -ea and -da and 10.4.0, its control passing; the Monitor probe fails 7 of 7 on main -ea and -da, 10.5.2 and 10.4.0; the highlighter repro fails 1 of 1 on main, 10.5.2 and 10.4.0]; the matches() body is byte-identical in 1584c05b27a, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main [read]; not fixed upstream: upstream main a9779ef3c193 has ParentsChildrenBlockJoinQuery.java byte-identical to ea57360912ed, and branch_10x and branch_10_6 have the same matches() [read-only check 2026-10-26, not executed]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record verdict new): 112 queries in the follow-up (two scripted prior-report searches of 51 and 24 queries over GitHub issues, PRs and commits, the downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 31 manual gh searches and API reads over apache/lucene, all of GitHub, the Elasticsearch, OpenSearch and Solr repositories, code search; 6 web searches), plus the finder's 38-query scripted prior-report search and the independent reproduction's and the intended-behaviour check's read-only searches; the file's history on main, branch_10x, branch_10_6, branch_10_5 and branch_10_4 and the changed files of all 408 open apache/lucene PRs were read; 22 hits read, none the same bug. Not fixed upstream and no open PR touches the file. Related, not reports: #14728 (added matches() in this form; its conversation and review comments never discuss matches()), #14565 (the feature request), #15041/#15042 and #14975/#15435 (test-only problems of the same class), #15469 (a reviewer's general statement that Weight#scorerSupplier() and Weight#matches() must agree for FilterWeight subclasses; it names no class, and this Weight extends Weight directly), #9276 / LUCENE-8229 (added ToParentBlockJoinQuery's matches() override, the sibling precedent), elastic/elasticsearch#96068 (downstream turns matches-mode highlighting off for nested and parent-join queries; predates the class) and the Gocene Go port (copies matches() line for line without comment). Gaps: GitHub search is lexical and best effort and does not index review comments (#14728's were read directly), list search covers lists.apache.org only, some searches were retried after rate limiting, and private reports to security@lucene.apache.org are not visible.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.4.0 and 10.5.2 release jars through the runner. The hunt and its checkers ran on mainea57360912ed(the file is byte-identical) and on 10.4.0, 10.5.0, 10.5.1 and 10.5.2. The fix was validated in the scratch worktree: the repro, the wholelucene/joinmodule with the hunt's tests added (115 tests, 3 skipped, 0 failures), and the format, ECJ and forbidden-API checks; the highlighter and Monitor tests and the hunt's bounded-exhaustive harness ran with the fixed class shadowed into the stock jars.
- Finding record
lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51 (class javadoc: returns the matching child documents of matching parents, at most childLimitPerParent; @lucene.experimental), :115-118 (visit calls visitLeaf), :191-197 (one scorerSupplier per leaf, LFV-167), :248-262 (matches() = parentWeight.matches OR childWeight.matches on the same doc, comment 'Combine non-null matches')lucene/core/src/java/org/apache/lucene/search/Weight.java:57-63 ('@param query the parent query') and :67-78 ('Returns Matches for a specific document, or null if the document does not match the parent query', @lucene.experimental)lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:134-164 (a non-null prohibited clause returns null; required and minShouldMatch clauses count non-null Matches)lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:200-219 (the sibling's own-scorer matches() override, 'We need to match on the parent instead', MATCH_WITH_NO_TERMS)lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:44-58 (MATCHER records a match for every document whose Weight#matches is non-null)lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:646-686 (MatchesAsserter: non-null on each hit, null on the document before a hit)
- The finding in 30 seconds
a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51, 248-262, 265-452 (314-347, 384-404, 425-429)a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/Weight.java:61, 67-78a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:134-164a Lucene main checkout (a9779ef3c193) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:110, 518-527; FieldOffsetStrategy.java:114-150a Lucene main checkout (a9779ef3c193) lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:44-62a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:200-219
- Why it looks unintended
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/Weight.java:61, 67-78a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51, 191-197, 257a Lucene main checkout (a9779ef3c193) lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:646-688a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:200-219a Lucene main checkout (a9779ef3c193) lucene/join/src/test/org/apache/lucene/search/join/TestParentsChildrenBlockJoinQuery.java (grep: no matches(CheckHits or QueryUtils)apache/lucene#15469 issuecomment-3747016657 (msfroh, 2026-01-13; read-only gh api, 2026-10-27)
- Suggested fix
a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:116-117 (visit: visitLeaf), QueryBitSetProducer.java:35-38, 61-88 (per-segment cache); lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:1253-1272 (postings falls back to analysis for an unrecognized query part), MemoryIndexOffsetStrategy.java:118, TermVectorOffsetStrategy.java:50, PostingsWithTermVectorsOffsetStrategy.java:42
- History
a Lucene main checkout (a9779ef3c193) lucene/CHANGES.txt:1281, 1336
- TestParentsChildrenMatchesParentDoc.java (JUnit test):
repro/LFV-508/src/org/apache/lucene/search/join/TestParentsChildrenMatchesParentDoc.java(63 lines)
Made in parallel with this doc from executed runs on main a9779ef3c193, with childLimitPerParent 1 (the finder's form of the example).
assets/LFV-508/bug.png(405 KB)
![Summary card. Panel 1, the example: block 1 holds doc 0 child 'size small', doc 1 child 'size large' and doc 2 parent 'red shirt'; block 2 holds doc 3 child 'size small' and doc 4 parent 'blue shirt'; the join has parentQuery text:red, childQuery text:size and childLimitPerParent 1; searcher.search(join, 10) returns doc 0 only (right); docs with non-null Weight#matches: expected [0], actual [0, 1, 2, 3]. Panel 2, inside matches() doc by doc: text:red matches only doc 2, text:size matches docs 0, 1 and 3; matches() returns non-null for docs 0 to 3 and null for doc 4, where only doc 0 should be non-null (ParentsChildrenBlockJoinQuery.java:248-262). Panel 3: as a MUST_NOT clause it nulls the Matches of real hits: +text:size -join finds docs 1 and 3 and the default UnifiedHighlighter returns 'size small', not '<b>size</b> small'; Lucene Monitor's HighlightsMatch.MATCHER reports docs [0, 1, 2, 3] for the stored query join where QueryMatch.SIMPLE_MATCHER reports [0]; right: search hits, counts and scores, and a fix sketch makes every value come out right. Footnote: fails on main a9779ef3c193 (-ea, -da) and on every release with the class, 10.4.0 to 10.5.2.](../assets/LFV-508/bug.png)