All findings · Lucene finding · LFV-508 · parentschildren-matches-ignores-join

ParentsChildrenBlockJoinQuery's Weight#matches ignores the join, so parents and children of the wrong parents get Matches

ParentsChildrenBlockJoinQuery finds the right child documents, but its Weight#matches says "match" for any document its parent query or child query matches on its own: parents, children of the wrong parents, children over the limit. So +x -join loses its highlights, and Lucene Monitor reports documents the query does not match.

Summary

Priority
Low, rank 357A Matches-only error in the experimental ParentsChildrenBlockJoinQuery (10.4+); hits, counts and scores are correct.
Impact
silent wrong results
Since
10.4.0 (#14728, merged 2025-06-13)introduced 2025-06-13 · about 1 year
Affected
10.4.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive differential test of the block joins on the real jar

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

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.

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.
The repro with a limit of 1 child per parent: the search returns doc 0 only, but 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.
  1. 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".
  2. The search is right: docs 0 and 1. You expect Weight#matches, which highlighters and BooleanQuery use to ask "does this one document match?", to say yes for docs 0 and 1 only.
  3. 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.
  4. A query that excludes the join breaks: +text:size -join finds the right documents, but Weight#matches says 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 as size large instead of <b>size</b> large.
  5. Lucene Monitor's HighlightsMatch matcher decides matches with Weight#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.

Who is affected

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.

Terms

  • Blocks: a parent and its children are indexed together with IndexWriter#addDocuments, children first, parent last. A BitSetProducer (here QueryBitSetProducer(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 most childLimitPerParent per parent (class javadoc, ParentsChildrenBlockJoinQuery.java:43-51 on main a9779ef3c193).
  • Weight#matches(context, doc): for one document, returns Matches (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's HighlightsMatch and the test framework's CheckHits rely on it.

The bug

  1. 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 (childDocCount against childLimitPerParent) and alignParentAndChildIterator() (:384-404) keeps the child inside the block of a matching parent, using the parent bit set. Parents are never returned.
  2. matches() skips all of it. ParentsChildrenBlockJoinWeight#matches (:248-262) asks parentWeight.matches(context, doc) and childWeight.matches(context, doc) about the same doc id, returns null only when both are null ("Neither matches"), and otherwise returns MatchesUtils.fromSubMatches of the non-null ones ("Combine non-null matches"). Nothing looks at the parent bit set, the doc's block parent or the limit.
  3. What the union does. For a valid parentQuery (it matches only parents; the scorer throws when it lands on a parentQuery match that is not a parent, validateParentDoc, :425-429) parentWeight.matches is 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 matches parentQuery (doc 2), a child that matches childQuery under 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).
  4. Who believes it. BooleanWeight#matches (BooleanWeight.java:134-164) returns null for a document when a prohibited clause's matches() is non-null (:142-146) and counts every non-null SHOULD clause toward minimumNumberShouldMatch (:153-162). The UnifiedHighlighter uses Weight#matches by default (UnifiedHighlighter.java:110, :518-527) and collects no offsets when it is null (FieldOffsetStrategy.java:143-150). Monitor's HighlightsMatch.MATCHER calls matches() for every document of the batch and records a match whenever it is non-null (HighlightsMatch.java:44-62).
  5. The sibling does it right. ToParentBlockJoinQuery's weight advances its own scorer to the document and returns MATCH_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 large instead of <b>size</b> large; the withWeightMatches(false) control highlights) [executed on main, 10.5.2 and 10.4.0]. As a SHOULD clause under minimumNumberShouldMatch the join can be counted on documents it does not match [executed through Monitor].
  • Lucene Monitor. HighlightsMatch.MATCHER differs from QueryMatch.SIMPLE_MATCHER in all 7 probe cases. Four use the stored join "children with body:size of acl:public parents": alone, with childLimitPerParent 1, and for one-document batches of a lone parent or a lone child. Three put body:size next to a join whose childQuery is type:child: of acl:public parents as a FILTER clause and as a SHOULD clause under minimumNumberShouldMatch 2, of acl:restricted parents as a MUST_NOT clause (true matches dropped) [executed on main, 10.5.2 and 10.4.0].
  • Other Weight#matches callers. 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 default IndexWriterConfig, the default codec and a plain IndexSearcher gives the same Weight#matches answers on main (assertions on and off), 10.4.0 and 10.5.2. The JUnit repros run under LuceneTestCase, 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 matches parentQuery or childQuery but not the join, and a caller that asks Weight#matches about 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 indexes CheckHits rejects 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.

Minimal repro

The test shows that ParentsChildrenBlockJoinQuery's Weight returns Matches for the parent document of a one-child block, which the join never returns.

TestParentsChildrenMatchesParentDoc.java (JUnit test)63 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.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));
      }
    }
  }
}
Command
# 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 -ea and -da.

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

More files, environment, full output39 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
    => 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)

Why it looks unintended

  • Weight#matches returns null "if the document does not match"; this query's hits are children of matching parents, up to the limit.
  • Lucene's own CheckHits.checkMatches fails on +text:size -join and, on an index of two red-parent blocks, on the join alone, where ToParentBlockJoinQuery passes.
  • The sibling ToParentBlockJoinQuery overrides matches() for exactly this reason: "We need to match on the parent instead".
The full argument248 words
  • The contract. Weight.java:68-69: "Returns Matches for a specific document, or null if 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 most childLimitPerParent per parent.
  • Lucene's own oracle. CheckHits.MatchesAsserter (CheckHits.java:646-688, run by QueryUtils.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), while ToParentBlockJoinQuery passes on that index [executed].
  • The sibling. ToParentBlockJoinQuery gives up child terms and returns MATCH_WITH_NO_TERMS from 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() and Weight#matches() are expected to be semantically the same". It was said about FilterWeight subclasses 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 calls matches(), CheckHits or QueryUtils.check; #14728's conversation and review comments and the issue #14565 never discuss matches().
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.

Suggested fix

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.

Check the join for the document's own block (validated by the checks) (recommended)

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 childQuery

Trade-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 +join still differ with the fix, all with childLimitPerParent 1 or 2: the conjunction moves the join with advance(), which does not count the block's earlier children, so the search itself returns a child that nextDoc() order puts over the limit, and the fixed matches() says no. That lives in the scorer's advance() (ParentsChildrenBlockJoinQuery.java:350-382) and is not in the registry.
  • Highlighting interplay. With any correct matches(), join hits whose parentQuery uses 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 evaluates Weight#matches on a reader whose highlighted field holds only the hit document, where the hit's parent cannot match. The POSTINGS_WITH_TERM_VECTORS source 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) and childWeight.scorer(context) on every call. If a sub-query is or contains a ParentsChildrenBlockJoinQuery, that inner Weight's one-scorerSupplier()-per-leaf guard (:191-197, LFV-167) throws on the second outer matches() 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's validateParentDoc check (:425-429): with an invalid parentQuery that also matches children, the scorer may throw while matches() just answers for the block's real parent.
  • Add a CheckHits.checkMatches (or QueryUtils.check) call to TestParentsChildrenBlockJoinQuery; no test of the class calls matches() today.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: the repro passes 4 of 4 with assertions on and off, together with TestParentsChildrenBlockJoinQuery (4 + 8 = 12 tests). In an earlier scratch worktree with the same fix, the whole lucene/join module 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, ecjLintMain and forbiddenApisMain pass (run after the added tests were removed, so on the production edit only).
  • With the fixed class shadowed into the stock a9779ef3c193 jars: 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, *:* -join and msm=2(join, text:b) on one and two segments, and the 40 advance() 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.

The ToParentBlockJoinQuery pattern: advance this Weight's own scorer

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.

Downstream note

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.

How it was found

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.matches check reported 3,512, 3,460 and 3,456 mismatches for childLimitPerParent 1, 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#matches non-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, *:* -Q and msm=2(Q, b). For example 446,600 of 1,091,376 checks failed for the join alone on one segment; ToParentBlockJoinQuery and the older ParentChildrenBlockJoinQuery had 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; Elasticsearch, OpenSearch, Solr; the web2026-10-26the 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-2631 manual searches and API readsonly #14728 and #15041 name the class
#14728 and #15469 threads2026-10-26conversation, 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 branches2026-10-26changed files of all 408 open PRs; the file's history on main, branch_10x, branch_10_6, branch_10_5 and branch_10_4no open PR touches the file; not fixed on any branch
the web2026-10-266 web searchesnothing 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)

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis 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 affectedthe 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 affectedthis 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 affectedfinder 3 of 3
10.4.0 affectedfirst 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 isolationfinder 3 of 3 fail with only ParentsChildrenBlockJoinQuery.java from 10.4.0 shadowed in
10.3.2, 9.12.3 no such classthe repro does not compile: the class does not exist

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateHarness 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 realOwn 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 realBuilt 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-sensitiveRan 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 keepRe-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 validatedRe-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.

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

Finding record

LFV-508, snapshot of 2026-11-06

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 main ea57360912ed (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 whole lucene/join module 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.

Lucene and upstream sources

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-78
  • a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:134-164
  • a Lucene main checkout (a9779ef3c193) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:110, 518-527; FieldOffsetStrategy.java:114-150
  • a Lucene main checkout (a9779ef3c193) lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:44-62
  • a 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-78
  • a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51, 191-197, 257
  • a Lucene main checkout (a9779ef3c193) lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:646-688
  • a Lucene main checkout (a9779ef3c193) lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:200-219
  • a 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

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

Repro files

About the diagrams

Made in parallel with this doc from executed runs on main a9779ef3c193, with childLimitPerParent 1 (the finder's form of the example).

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.