All findings · Lucene finding · LFV-1048 · parentschildren-blockjoin-advance-ignores-child-limit

A limited ParentsChildrenBlockJoinQuery returns children beyond childLimitPerParent when a filter, a sort or explain() advances it

With a finite childLimitPerParent, ParentsChildrenBlockJoinQuery's advance() starts counting children at the target document, so filters, sorted searches and explain() can get children beyond the limit: the join alone matches a and c, yet +join #id:b returns b. Silent, in 10.4.0 through 10.6.0 and main.

Summary

Priority
Medium, rank 57For users of the experimental ParentsChildrenBlockJoinQuery (released 10.4+, no known downstream user) with a finite childLimitPerParent, ordinary conjunctions, sorted searches and explain silently return children over the limit, the same rarely-used experimental wrong-hits profile as the RescoreTopNQuery group, so it closes that group above the score-only LFV-424 and well above its score-only sibling LFV-218.
Impact
silent wrong results
Since
10.4.0 (#14728, merged 2025-06-13; the class's first release)introduced 2025-06-13 · about 1 year
Affected
10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main (all run)
Status
documented
Found by
a bounded-exhaustive explain-vs-hit check of block-join queries, then code reading

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

ParentsChildrenBlockJoinQuery returns, for each parent matching parentQuery, at most childLimitPerParent of its children matching childQuery. nextDoc() counts them from the start of each block; advance(target) starts counting at target, so it can return a child that nextDoc() never returns.

Background

  1. Block join. Each parent is indexed right after its children, in one addDocuments block; a parent filter (BitSetProducer) marks which documents are parents.
  2. ParentsChildrenBlockJoinQuery (10.4.0, experimental) returns child documents: the children matching childQuery whose parent matches parentQuery, at most childLimitPerParent per parent (default Integer.MAX_VALUE). Upstream's randomized test expects the first N matching children of each parent, in doc order.
  3. Iterators. A query's DocIdSetIterator has nextDoc() (the next match) and advance(target) (the first match at or after target), which must give the same matches as calling nextDoc() until target. Conjunctions, sort skipping and explain() use advance().
Two blocks with childLimitPerParent 1: children a (doc 0) and b (doc 1) with parent p1 (doc 2), child c (doc 3) with parent p2 (doc 4). nextDoc() returns a and c; advance(1), called by the id:b filter, returns b, so join AND id:b has 1 hit instead of 0.
The repro's five documents: nextDoc() counts a and moves on to p2, so the join matches a and c; advance(1), called by the id:b filter, starts counting at b and returns it. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index two blocks with addDocuments: children a (doc 0) and b (doc 1) under parent p1 (doc 2), then child c (doc 3) under parent p2 (doc 4). Build ParentsChildrenBlockJoinQuery(parents, type:parent, type:child, childLimitPerParent = 1).
  2. On its own the join matches a and c, the first matching child of each parent: count(join) = 2. So you expect +join #id:b (join AND id:b) to return 0 hits, and explain(join, 1) to say "Not a match".
  3. Instead count(+join #id:b) = 1 (doc b) and explain(join, 1) reports a match, with assertions on or off, on 10.4.0 through 10.6.0 and main.
  4. Why: id:b is the cheaper clause, so it leads and asks the join to advance(1). advance() jumps straight to doc 1 and counts it as p1's first child; nextDoc() would have counted a first and moved on to p2.
  5. The answer depends on how the query is driven: with only the first block indexed, the same query returns 0 hits; a plain sorted search over 5,000 blocks puts an over-limit child first once top-N skipping starts.

Who is affected

Users of the experimental ParentsChildrenBlockJoinQuery (10.4.0 and later) who set a finite childLimitPerParent, the parameter it was added for, and combine the join with a cheaper required or filter clause, sort its results, or call explain(). Default codec and searcher. The default limit and the join alone with unsorted search() or count() are fine.

Mechanism, terms and numbers643 words

ParentsChildrenBlockJoinQuery (new in 10.4.0, @lucene.experimental) returns, for each parent that matches parentQuery, its children that match childQuery, at most childLimitPerParent of them. Its iterator's nextDoc() counts a block's matching children from the block's first child, so the hits are the first N matching children of each parent in doc order, as the upstream randomized test defines them. advance(target) instead jumps to the first matching child at or after target and starts counting there, so the matching children of the block before target are never counted and advance() can return a child that nextDoc() never returns, which breaks the DocIdSetIterator#advance contract. A caller whose advance() enters a block past matching children the iterator has not counted can get the extra children, for example a cheaper MUST or FILTER clause that leads a conjunction (+join #id:b returns b in a 5-document repro), a sorted top-N search once competitive skipping starts, and explain(), which tests advance(doc) == doc. Silent, with assertions on or off, default codec and searcher; the default limit (Integer.MAX_VALUE) is not affected. Present since the class was added by #14728, so every release with it (10.4.0 to 10.6.0) and main.

The code (main 645d4fe14a20, ParentsChildrenBlockJoinQuery.java)

  1. nextDoc() (:313-347) advances the child iterator only while childDocCount < childLimitPerParent (:315-317). Once the limit is reached, or the child passed the parent, it resets the count and moves to the next matching parent (:322-330), and alignParentAndChildIterator() moves the child iterator to the first child of that parent's block, parentBits.prevSetBit(parentDoc - 1) + 1 (:386, :390-392). So within each block it returns the first N matching children.
  2. advance(target) (:349-382) calls childIt.advance(target) (:355). If the count is used up or the child is past the current parent (:356), it resets childDocCount = 0 (:358) and moves the parent iterator to the child's block, or on to the next parent when the count is used up inside the current block (:359-367); then childDocCount++ (:380). The block's matching children between its first child and target are skipped and never counted. Inside the current block it adds 1 only for the child it returns, so with a limit of 2 or more it undercounts there too.
  3. Weight#explain (:170-188) tests scorer.iterator().advance(doc) == doc on a fresh scorer (:173).
  4. Who advances the join. Lucene's conjunctions sort their clauses by cost and let the cheapest lead (ConjunctionDISI.java:132, :161; ConjunctionBulkScorer.java:54), using each iterator's cost(); the join iterator's cost is min(childCost, parentCost * childLimitPerParent) (:407-416, formula at :414; the scorer supplier's cost() at :229 is the same formula).

The repro, step by step (limit 1)

callwhat the code doesresult
count(join)nextDoc(): child 0 (a), count 1; limit reached, so next parent 4 and the child iterator moves to doc 3 (c){a, c}
+join #id:bid:b costs 1, the join min(2 x 1, 3) = 2 (2 parents, 3 children), so id:b leads, finds doc 1 and calls join.advance(1) on a fresh iterator
advance(1)childIt.advance(1) = 1; 1 >= parentDoc (0): count = 0, parentIt.advance(1) = 2; doc 1 lies in p1's block; count 1returns 1 (b)
explain(join, 1)the same advance(1) on a fresh scorermatch
first block onlythe costs tie (1 and 1); the observed 0 hits fit the join leading: nextDoc() = 0, then advance(1) with the count used up moves past p10 hits

Why the join alone is right

Unsorted search() and count() drive the join with nextDoc() only, so they return the first N matching children of each parent; explain() of a real hit lands on that hit. Only paths that call advance() past uncounted children go wrong.

Exposure and likelihood493 words
  • Who is hit. Applications using ParentsChildrenBlockJoinQuery (10.4.0 and later) with a finite childLimitPerParent, the parameter the class was added for (#14565). The default Integer.MAX_VALUE is not affected: 0 of 420,758 checks in the bounded-exhaustive model. No use of the class was found in Elasticsearch, OpenSearch or Solr.
  • Triggers (public API; default codec, IndexWriterConfig and IndexSearcher; a CheckJoinIndex-valid index):
    • a conjunction where a cheaper MUST or FILTER clause leads, for example a child-attribute filter, the composition the feature request describes (the repro);
    • a plain sorted search search(join, 10, sort) with no other clause, once the sort's competitive iterator starts skipping: 5,000 blocks [c0, c1, parent], limit 1, a long sort field with points and doc values, c1 tiny in five blocks; the top hit is c1-3000, an over-limit child (totalHits = 1002+), on main with assertions on and off and on 10.5.2. With the recommended fix the top 10 are c0-0 to c0-9 and the total stays a lower bound (1001+ of 5,000 matches), consistent with skipping still working (the probe does not instrument skipping);
    • IndexSearcher.explain on a non-hit, which reports a match.
  • Same root, notes only. As a MUST_NOT clause the join can exclude documents it does not match (run on 10.5.2 and main only). On 10.5.2 the bounded model finds such cases (up to 6 documents); on main it finds none up to 7 documents, but a separate 15,000-document probe, where the required clause skips past a 4,096-document window into a block, reproduces one (+id:12000 -join returns 0 hits instead of 1). With an explicit LRUQueryCache that caches the join, the conjunction becomes correct (1 hit without the cache, 0 with it), so the answer can also change with caching; not shown with the default caching policy.
  • Effect. Silent wrong results: more children per parent than the limit, and an explain() that disagrees with search() for the query alone. Every extra hit still matches childQuery, has a parent that matches parentQuery and satisfies every other required clause.
  • Scale. A differential check against a reference join written from the javadoc, over every block layout up to 7 documents and every match pattern: 11,180 mismatches in 1,683,032 checks on main (limit 1: 6,562; limit 2: 3,827; limit 3: 791; MAX: 0). With limit 1, 1,772 of 72,818 single-document FILTER conjunctions and 1,973 of 72,818 explain checks are wrong; 0 mismatches with either fix. The model covers one segment, no deletions, blocks that end in a parent, disjoint parent and child queries and limits 1, 2, 3 and Integer.MAX_VALUE; it says nothing about multi-segment indexes, deletions or other query shapes.
  • Not affected: the join alone with unsorted search() and count(), explain() of real hits, the default limit. Intra-segment concurrency is rejected on purpose (ParentsChildrenBlockJoinQuery.java:191-197), so it is not a trigger.
  • Likelihood: plausible. A rarely used, experimental class, but deterministic for its users once a filter, a sort or explain() is involved.

Minimal repro

One JUnit test on the public API, in Lucene's join test package: five documents in two blocks, ByteBuffersDirectory, default IndexWriterConfig, query cache off. It asserts count(join) == 2 (passes), count(+join #id:b) == 0 (fails with 1) and that explain(join, 1) is not a match.

TestParentsChildrenChildLimitAdvance.java (one test, public API)73 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.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.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestParentsChildrenChildLimitAdvance extends LuceneTestCase {

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

  public void testAdvanceHonoursChildLimitPerParent() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      w.addDocuments(List.of(doc("child", "a"), doc("child", "b"), doc("parent", "p1")));
      w.addDocuments(List.of(doc("child", "c"), doc("parent", "p2")));
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setQueryCache(null);
        Query parent = new TermQuery(new Term("type", "parent"));
        Query join =
            new ParentsChildrenBlockJoinQuery(
                new QueryBitSetProducer(parent),
                parent,
                new TermQuery(new Term("type", "child")),
                1);
        Query joinAndB =
            new BooleanQuery.Builder()
                .add(join, Occur.MUST)
                .add(new TermQuery(new Term("id", "b")), Occur.FILTER)
                .build();
        // expected: join alone matches {a, c}, so join AND id:b is empty and b is not a match;
        // actual: b matches
        assertEquals(2, searcher.count(join));
        assertEquals(0, searcher.count(joinAndB));
        assertFalse(searcher.explain(join, 1).isMatch());
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:join:test --tests TestParentsChildrenChildLimitAdvance -Ptests.seed=5EED1048
Expected
count(+join #id:b) = 0, because the join alone matches only a and c (count(join) = 2).
Observed
count(+join #id:b) = 1: AssertionError: expected:<0> but was:<1>, with assertions on and off.

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

More files, environment, full output358 words

The hunt's minimal repro after ./gradlew :lucene:join:applyGoogleJavaFormat, which wraps two long lines and changes nothing else, so it passes the build's format check. The documents and the query are fixed; single thread, one segment. The seed fixes the test framework's randomized environment: locale, time zone, codec, similarity and index settings (the gradle run prints codec=Asserting(Lucene104), sim=Asserting(RandomSimilarity(queryNorm=false)), maxPointsInLeafNode=1990). The failure does not come from that randomization: a plain program without the test framework, using the real default codec and similarity, prints join.count=2 conjunction.count=1 explain(doc 1).isMatch=true on main and on the 10.6.0 release commit, with assertions on and off. The query cache is off because the test framework may install a randomly caching default cache, and caching the join hides the bug.

On stock Lucene the count assertion fails first, so the explain assertion is not reached; an earlier version of the test, which prints the explain result in the count assertion's message, fails with explain(join, doc 1).isMatch()=true expected:<0> but was:<1>.

Regression test: this same test; it passes with either fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; this test also fails on the 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 release jars, assertions on and off
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestParentsChildrenChildLimitAdvance.java $LUCENE/lucene/join/src/test/org/apache/lucene/search/join/
cd $LUCENE && ./gradlew :lucene:join:test --tests TestParentsChildrenChildLimitAdvance -Ptests.seed=5EED1048
# assertions off: add -Ptests.asserts=false (same failure)

Observed output

# main 645d4fe14a20, gradle, -Ptests.seed=5EED1048 (the same with -Ptests.asserts=false)
TestParentsChildrenChildLimitAdvance > testAdvanceHonoursChildLimitPerParent FAILED
    java.lang.AssertionError: expected:<0> but was:<1>
        ...
        at org.apache.lucene.search.join.TestParentsChildrenChildLimitAdvance.testAdvanceHonoursChildLimitPerParent(TestParentsChildrenChildLimitAdvance.java:68)
:lucene:join:test (FAILURE): 1 test, 1  failure

# line 68: assertEquals(0, searcher.count(joinAndB))
# standalone runner, main -ea / -da and the 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 release jars -ea / -da: fail 1/1, same message at line 68
# plain program (no test framework), main and 10.6.0 -ea / -da:
join.count=2 (expected 2) conjunction.count=1 (expected 0) explain(doc 1).isMatch=true (expected false)

# with fix A: the test passes; :lucene:join:test (SUCCESS): 105 tests, 3 skipped (assertions on and off);
# :lucene:join:check (format, ecjLint, forbiddenApis, tests): BUILD SUCCESSFUL
# with fix B: the test passes; :lucene:join:test (SUCCESS): 105 tests, 3 skipped (assertions on)

Why it looks unintended

  • DocIdSetIterator#advance must behave like a nextDoc() loop to the target; nextDoc() never returns b, advance(1) does.
  • Upstream's randomized test expects the first N matching children per parent, and the feature request expects a limited join in a BooleanQuery to intersect.
  • The result changes with clause costs: with only the first block, the same query returns 0 hits.
The full argument393 words
  • The core contract. DocIdSetIterator#advance: "When target > current it behaves as if written: while ((doc = nextDoc()) < target) {} return doc;" (DocIdSetIterator.java:144-152). With limit 1, nextDoc() never returns doc 1, but advance(1) does.
  • The query's composition. Occur.MUST: clauses that "must appear in the matching documents"; Occur.FILTER: "Like MUST except that these clauses do not participate in scoring" (BooleanClause.java:27, :35). So +join #id:b is the intersection of {a, c} and {b}: empty.
  • Upstream's definition of the hits. testWithRandomizedIndex adds a child to the expected set only while matchingChildrenCount <= childLimitPerParent, walking each block in doc order (TestParentsChildrenBlockJoinQuery.java:119-129).
  • The feature request assumes limit-then-intersect. #14565 (Jinny-Wang, the class's author): with a limit of 1, a join over [child1, child2, child3, Parent1] [child4, child5, Parent2] "will return child1 and child4", so combined with title:milk (child3, child5) "the boolean query will return no match"; it proposes putting a childQuery inside the join for that reason.
  • The authors guard against counting mid-block. The #14728 commit message: "this query type will not work with intrasegment concurrency, as it may split a block, leading to overcollection of child documents". The query throws for intra-segment partitions (ParentsChildrenBlockJoinQuery.java:191-197), and three places in the test class say "Do not use Concurrency.INTRA_SEGMENT as that would break childLimitPerParent counting" (TestParentsChildrenBlockJoinQuery.java:185 in testAdvance, :246 in the shared helper test(...), :369 in testExplain). advance() into the middle of a block is an unguarded path to the same over-collection.
  • No test advances into a block where the limit binds. testAdvance advances only to a block's first child or past the end (TestParentsChildrenBlockJoinQuery.java:212-219); testExplain's explain(query, 4) (:399) advances a fresh scorer to the middle of block [3, 4, 5], past matching child 3, but its limit 2 never binds in that index (:336-386); and the class's tests do not run QueryUtils.check, whose checkFirstSkipTo advances a fresh scorer to every document between hits (QueryUtils.java:408-447).
  • Explain disagreeing with search is treated as a bug. On #15041 benwtrent called an explain match for a non-hit in this class's test "troublesome as that indicates we are matching on a filtered out document now, or that explain is broken" (that failure had another cause, merge-ordered doc ids, and was fixed in the test).
The devil's-advocate check166 words

The strongest case for "intended" (built by the devil's-advocate verifier and the adjudication): the javadoc promises only a maximum, and each pass of the iterator does return at most N children per parent; it never says which N. #14565 motivates the limit as a diversification budget ("to ensure that docs from more parents are considered"), so "limit after the outer filter" could look useful. The class is @lucene.experimental, and explain() is a development aid that is normally called on hits.

Why it fails: one query on one index gives different answers depending on things that must be transparent: clause costs (one block: 0 hits; two blocks: b), query caching (the join cached: 0 hits) and sort skipping (a plain sorted search puts c1-3000 first). No documentation could describe that. "Limit after the filter" holds only when the filter happens to lead. The advance() contract is not optional, @lucene.experimental is about API stability, and explain() is wrong for the query alone. Verdict: not intended.

Suggested fix

Recommended Count from the target's block start, keep skipping whole blocks (validated)

For a finite limit, advance(target) first jumps to the first matching child of the target's block (parentBits.prevSetBit(target - 1) + 1), counts from there and steps with nextDoc() to the target, so whole blocks are still skipped (+38 lines). The repro, all 105 lucene/join tests and :lucene:join:check pass with it.

if (childLimitPerParent != DEFAULT_CHILD_LIMIT_PER_PARENT) {
  int blockStart = target >= parentBits.length() ? target
      : target == 0 ? 0 : parentBits.prevSetBit(target - 1) + 1;
  if (blockStart > childDoc) {
    childDoc = childIt.advance(blockStart);   // skip whole blocks
    childDocCount = 0;
    // ... align parent and child, scores, childDocCount++
    if (childDoc >= target) return childDoc;
  }
  int doc;
  while ((doc = nextDoc()) < target) {}    // count inside the block
  return doc;
}
All options and trade-offs3 options ยท 608 words

Two fixes were built and run; both make advance() return exactly what a nextDoc() loop returns for a finite limit and leave the default limit's code path unchanged.

Count from the target's block start, keep skipping whole blocks (validated) (recommended)

For a finite limit, advance(target) computes the first document of the target's block, parentBits.prevSetBit(target - 1) + 1. If that lies after the current child, it moves the child iterator there with one advance, aligns the parent, resets the count and counts the child it lands on as the block's first; then it steps with nextDoc() until it reaches target (+38 lines in ParentsChildrenBlockJoinQuery.java).

if (childLimitPerParent != DEFAULT_CHILD_LIMIT_PER_PARENT) {
  int blockStart = target >= parentBits.length() ? target
      : target == 0 ? 0 : parentBits.prevSetBit(target - 1) + 1;
  if (blockStart > childDoc) {
    childDoc = childIt.advance(blockStart);   // skip whole blocks
    childDocCount = 0;
    // ... align parent and child, scores, childDocCount++
    if (childDoc >= target) return childDoc;
  }
  int doc;
  while ((doc = nextDoc()) < target) {}    // count inside the block
  return doc;
}

Trade-offs

  • Inside the target's block it steps child by child, at most childLimitPerParent counted children before it moves on to the next parent; blocks before the target's block are still skipped with one advance.
  • 38 lines instead of 7. A reviewer may want the original's childDoc <= parentDoc ? parentIt.nextDoc() : parentIt.advance(childDoc) guard (:359-363) mirrored where the fix calls parentIt.advance(childDoc); it only matters if the child query matched a parent document.
  • No benchmark was run.

What the checks validated

  • Scratch worktrees of 645d4fe14a20, JDK 25: the minimal test fails on stock and passes with the fix (gradle with assertions on, the standalone runner with them off).
  • The whole lucene/join module with the fix and the test: 105 tests in 17 suites, 3 skipped, 0 failures, assertions on and off.
  • :lucene:join:check (google-java-format, ecjLint, forbiddenApis, tests): BUILD SUCCESSFUL; the formatter leaves the fix unchanged.
  • Earlier, by the hunt: 0 mismatches in the bounded-exhaustive model (up to 7 documents); an independent verifier's 4 tests pass; the sorted-search probe returns c0-0 to c0-9, and its 1001+ total is consistent with skipping still active.
  • Not run: the full Lucene suite, nightly tests, benchmarks.

Step with nextDoc() for a finite limit (validated)

For a finite limit, advance(target) runs the DocIdSetIterator#advance reference loop (+7 lines; the sketch of the two hunt units that found the bug).

if (childLimitPerParent != DEFAULT_CHILD_LIMIT_PER_PARENT) {
  // The limit counts the matching children of a block from its first child, so the
  // children between the current doc and the target must be counted, not skipped.
  int doc;
  while ((doc = nextDoc()) < target) {}
  return doc;
}

Trade-offs

Obviously correct, but a finite limit then never skips: a sparse filter leading the conjunction makes the join walk all its matches up to each target (bounded by the join's own cost, matching parents x limit).

What the checks validated

  • The minimal test passes with it (gradle and the standalone runner, assertions on); the whole lucene/join module: 105 tests, 3 skipped, 0 failures (assertions on).
  • Earlier, by the hunt: 0 mismatches in the bounded-exhaustive model; the 8 upstream tests pass; on the 10.4.0 jars the class with only this change passes.
  • Not run: the module with assertions off, :lucene:join:check, benchmarks.

Document that the limit depends on how the query is driven (not recommended)

Leave the code and say in the javadoc which N children are returned depends on the caller.

Trade-offs

The answer would depend on clause costs, caching and sort skipping, and explain() would stay wrong for the query alone; no check supports it.

Downstream note

Not a security issue: every extra hit still matches the child query, belongs to a parent that matches the parent query and passes every other required clause, so no filter is bypassed; only the per-parent limit is exceeded. An application that uses the limit itself as an exposure cap would show more children than intended; that depends on downstream use.

How it was found

An automated bounded-exhaustive check compared explain() with the actual hits on every small block-join index and flagged a non-hit explained as a match; reading advance() showed why. An adjudication's differential check against a reference join written from the javadoc then found 11,180 mismatches in 1,683,032 checks, none for the default limit.

The method in full173 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier), on the stock main jar.

  • First signal (2026-10-26). A hunt unit's explain-vs-hit check over block-join indexes (oracle: explain(doc).isMatch() equals "doc is a hit") reported a non-hit child explained as a match; reading advance() found the cause and a probe confirmed the conjunction effect. A second unit found the same root cause the same day, by reading the iterator and with its own advance-vs-nextDoc and FILTER-conjunction checks; its candidate was withdrawn as a duplicate.
  • Widened by the adjudication unit (2026-10-29). A differential check against an independent reference join written from the class javadoc: every block layout of up to 7 documents (10,922 indexes), every child/parent match pattern, limits 1, 2, 3 and MAX, with oracles for nextDoc/count, a fresh advance to every target, nextDoc then advance, advance chains, single-document FILTER conjunctions, MUST_NOT and explain. 11,180 mismatches in 1,683,032 checks; 0 for MAX; 0 with either fix.
  • Then reduced to one five-document JUnit, reviewed by cross-model reviewers and verifiers (see the checks below).

Prior reports, history and checks

Prior reports

No prior report in GitHub issues and PRs, the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, three downstream projects or the web (searched 2026-10-29, refreshed 2026-10-30). The closest item, #15041 (an explain match for a non-hit in the class's own test), had a different cause and was fixed in the test.

Searches5 searches
  • Closest symptom, different cause: #15041 (benwtrent, closed): explain(query, 4).isMatch() was true for a non-hit in testExplain; diagnosed as merges reordering doc ids and fixed in the test (#15042). Its limit 2 never binds there, so this bug cannot show.
  • Context, not reports: #14728 (the introducing PR; nothing about advance() into a block, conjunctions or explain), #14565 (open feature request; states limit-then-intersect), #14975 / #15435 (test-only merge-order fix), #7415 (LUCENE-6354, min/max children for ToParentBlockJoinQuery, design only), LUCENE-3600 and #6935 (LUCENE-5873), older advance assertions in other block-join classes.
  • An outside Go port copies the same advance() line for line, with no issue about it.
  • Not fixed upstream: the file's upstream history is still #14728 and #16519 (a signature change); upstream main 716d6f216f5 keeps the same advance().
  • Gaps: keyword search only; GitHub search does not index PR diffs (recent PRs were checked by their file lists); private channels cannot be searched.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2916 tool queries plus about 25 manual searches (class name alone and with advance, explain, filter, BooleanQuery; childLimitPerParent; block join advance/limit; in:comments; open PRs); the changed files of the 66 PRs updated since 2026-10-27only #14728, #14565, #15041 and old unrelated block-join items; no report
Threads read in full2026-10-29#14728 (conversation, reviews, review comments), #14565, #15041, #14975, #15435no discussion of advance() into a block, conjunctions or explain over the limit
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2920 queriesonly GitHub mirror and commit mails for the items above
JIRA archive, downstream projects, web2026-10-294 JIRA queries; Elasticsearch, OpenSearch, Solr and GitHub-wide code search; 6 web queriesno report; no downstream use of the class
GitHub refresh2026-10-30class name and childLimitPerParent over issues and PRs; the 8 PRs updated since 2026-10-29; the file's upstream historynothing new; not fixed

History

Present since the class was added by #14728 (merged 2025-06-13 on main, backported to branch_10x; first released in 10.4.0). Fails on 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main; on the 10.4.0 jars, the class with only advance() fixed passes. Older releases do not have the class.

Versions tested8 rows

Introduced by #14728 "[BlockJoin] Add ParentsChildrenBlockJoinQuery" (Jinny-Wang, reviewed by msfroh and karthi01ec; implements #14565): squash commit 1584c05b27a on main, 2025-06-13; branch_10x backport 61b32ff6d37, committed 2025-09-04, with the same file. It added the class, advance() and explain() together; git blame at 645d4fe attributes every line of advance(), nextDoc() and explain() to it. Listed under 10.4.0 in lucene/CHANGES.txt:1353. Later changes to the file are signatures only (8f2f9f3f71e "Fix build" on branch_10x; #16519, 2026-08-19, on main); branch_10x itself was decommissioned on 2026-10-29 (48cf3f515d6, #16911), which deletes the file there, while the release branches and main keep it. The release rows below come from two tests on the same index: the minimal test (10.4.0 to 10.6.0 and main, assertions on and off) and the finder's earlier test, whose failure message also carries the explain() result.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (gradle and standalone runner)
10.6.0 (released, 4ae946e5a9b) affectedminimal test fails, assertions on and off (release jars); the earlier test too
10.5.2 affectedminimal test fails, assertions on and off (release jars); the earlier test too
10.5.0, 10.5.1 affectedminimal test fails, assertions on and off (release jars); the earlier test fails with assertions on
10.4.0 (first release with the class) affectedminimal test fails, assertions on and off (release jars); the earlier test too
10.4.0 jars with the class unchanged vs with only the advance() fix commit-levelthe earlier test: unchanged fails; with the fix passes
10.3.2, 9.12.3 no classthe class does not exist; the earlier test does not compile
fixes on 645d4fe14a20 not affectedthe minimal test passes with either fix; lucene/join: 105 tests, 0 failures

Independent checks

Reproduced by the finder, an independent reproducer, cross-model reviews (OpenAI GPT-5.6) that wrote and ran their own tests, a devil's-advocate verifier who found the sorted-search trigger, an adjudication, a credibility audit and a no-noise gate; every one rates it real.

Checks and verdicts379 words

Each check ran its own test or re-ran an earlier one on stock main; the controls (the default limit, the nextDoc path, the fixes) pass.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (judge unit, bounded-exhaustive check) candidate11,180 mismatches in 1,683,032 checks (MAX 0, either fix 0); the test fails with -ea and -da; both fixes pass the 8 upstream tests of the class.
Cross-model review, refute lens (OpenAI GPT-5.6) real (high)Own test on six documents: nextDoc gives {0, 4}, a fresh advance(2) gives 2; conjunction and explain over-match; controls pass; both fixes pass.
Independent reproducer realOwn test fails 3 of 4 (the nextDoc control passes) on main, 10.6.0, 10.5.2 and 10.4.0; both fixes make all 4 pass.
Cross-model, intended lens (OpenAI GPT-5.6) real (high)The works-as-designed case is weak; own test: nextDoc set [0, 1, 4], advance(2) = 2, conjunction count 1, explain a match.
Intended-behaviour devil's advocate real (high)No intended reading survives; found the sorted-search trigger and the cache dependence.
Adjudication confirmed (high)Re-ran the finder's test (-ea, -da) and the independent reproduction's (-ea) on main: all fail; not noise.
Registry re-run, audit and no-noise gate keep; claimStock main fails with -ea and -da and passes with the recommended fix; the minimal repro fails with -ea and -da and passes with both fixes.
This doc's runs still reproduces (2026-10-30)Stock main fails through gradle (assertions on and off) and the standalone runner; with the recommended fix the test, all 105 lucene/join tests and :lucene:join:check pass; with the alternative fix the test and the module pass.
Independent fact check (2026-10-30) substantively correct; two corrections appliedRe-ran the doc's gradle command on 645d4fe14a20 (fails at line 68, assertions on and off), a cost probe (join 2, type:child 3, id:b 1), both fixes (pass) and the minimal test on the 10.4.0 to 10.6.0 release jars (10 of 10 fail). Corrected: the join's cost arithmetic, and the claim that no upstream test advances into a block.
Cross-model fact check (OpenAI GPT-5.6) substantially sound; corrections appliedRead the brief against the code and the saved runs; the intended-behaviour case is weak. Its 2 factual errors, 3 overclaims and 4 missing caveats were applied.

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

Finding record

LFV-1048, snapshot of 2026-11-06

Registry title
ParentsChildrenBlockJoinQuery: advance() does not count the matching children it skips, so conjunctions, sorted searches and explain return children beyond childLimitPerParent
Area
join / block join iterator (ParentsChildrenBlockJoinQuery advance() and Weight#explain; reached through ConjunctionDISI, sorted-search competitive iterators and IndexSearcher.explain)
Priority
Medium, rank 57: For users of the experimental ParentsChildrenBlockJoinQuery (released 10.4+, no known downstream user) with a finite childLimitPerParent, ordinary conjunctions, sorted searches and explain silently return children over the limit, the same rarely-used experimental wrong-hits profile as the RescoreTopNQuery group, so it closes that group above the score-only LFV-424 and well above its score-only sibling LFV-218.
Impact class
silent wrong results
Impact note
Applications on 10.4.0 through 10.6.0 or main that use the experimental ParentsChildrenBlockJoinQuery with a finite childLimitPerParent (the default Integer.MAX_VALUE is not affected) get children beyond the limit, with no error, whenever something other than the join's own nextDoc() drives its iterator: a cheaper child-level MUST/FILTER clause leading a conjunction, a sorted top-N search once competitive-iterator skipping starts (about 1000+ hits in the intended-behaviour check's probe), or IndexSearcher.explain on a non-hit, which is reported as a match; default codec, IndexWriterConfig and IndexSearcher, -ea or -da, five documents suffice; the join alone with unsorted search() and count() is correct.
Summary
You index the blocks [a, b, p1] and [c, p2] and build a ParentsChildrenBlockJoinQuery with childLimitPerParent 1, which on its own matches a and c, the first matching child of each parent. You expect the BooleanQuery +join #id:b to match nothing and explain(join, b) to say it is not a match. Instead the conjunction returns b and explain reports b as a match: advance(target) starts counting the limit at the target instead of at the block's first child, so it returns a child that nextDoc() never returns, which breaks the DocIdSetIterator#advance contract.
Introduced
1584c05b27ac31fbccb0ab328bf9f8eb6a7de414 (main, squash merge of apache/lucene#14728 '[BlockJoin] Add ParentsChildrenBlockJoinQuery', author Jinny-Wang, reviewed by msfroh and karthi01ec; implements feature request #14565) and its branch_10x backport 61b32ff6d3772906b12b9e7f855b8607e9261371 (same file blob 0d14191bace) [read: git blame at 645d4fe attributes every line of advance(), nextDoc() and explain() to 1584c05b27a; the file's only later commits, 8f2f9f3f71e 'Fix build' on branch_10x and bc601411a31 (#16519) on main, change signatures only; exec: commit-level check on the 10.4.0 jars: the 10.4.0 class shadowed unchanged still fails, the same file with only the 7-line advance() change (fix/simple) passes] (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])
Affected versions
[exec] main 645d4fe14a20 (-ea and -da: finder, adjudication, the independent reproduction, a separate re-run, the intended-behaviour check and the re-run) and every release with the class: 10.4.0 (-ea and -da), 10.5.0, 10.5.1, 10.5.2 (-ea and -da) and 10.6.0 (SHA-1-verified Maven Central jars, commit 4ae946e5a9b; -ea and -da). Released in 10.6.0: the tag releases/lucene/10.6.0 exists (tag object e29d290eeb05, peels to 4ae946e5a9b; git ls-remote 2026-10-29). [read] upstream main 716d6f216f5 (same file blob 4274dc5e8bb as 645d4fe) and branch_10_6 head 7e5aec4370e (same blob bc60a3f3331 as the 10.6.0 tag). Not applicable: 10.3.2, 9.12.3 and older (no such class). Not new in 10.6
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (2026-10-29, read-only). The prior-report record (verdict new): a scripted prior-report search of 51 queries (16 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 6 web; 2,576 distinct hits, 0 high-overlap), about 25 manual GitHub search-API queries (apache/lucene incl. in:comments and open PRs; elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr; code search across GitHub), the changed-file lists of every apache/lucene PR updated since 2026-10-27 (66, none touches lucene/join, ParentsChildren* or DocIdSetIterator.java), full reads of #14728 (conversation, reviews and review comments), #14565, #15041, #14975 and #15435, and the file's commit history on main and branch_10x. Related, not this bug: #14728 (the introducing PR; nothing about advance() into a block, conjunctions or explain over-matching), #14565 (open feature request; states the limit-then-intersect semantics for a BooleanQuery), #15041 and #14975 (closed test failures from merge-order doc ids, fixed in the test by #15042 and #15435; testExplain's limit never binds there), #7415 (LUCENE-6354, min/max children for ToParentBlockJoinQuery, design only), LUCENE-3600 and #6935 (LUCENE-5873) (old advance assertions in other block-join classes); an outside Go port (FlavioCFOliveira/Gocene) copies the same advance() without comment. Refresh (2026-10-29): 'ParentsChildrenBlockJoinQuery' still finds only #14728 and #15041, 'childLimitPerParent' only #14565 and #14728; no apache/lucene PR was updated after a recent cutoff; no open PR mentions the class; the file's newest upstream commit is #16519 (author date 2026-08-19). Not fixed upstream (upstream main 716d6f216f5 keeps the same advance()).
Lucene commit tested
main 645d4fe14a20 (2026-10-28; ParentsChildrenBlockJoinQuery.java is byte-identical at upstream main 716d6f216f5, the head on 2026-10-30): the minimal test fails through gradle and through a standalone runner, with assertions on and off. Both fixes were validated in scratch worktrees of 645d4fe14a20: the minimal test and the whole lucene/join module (the recommended fix with assertions on and off, plus :lucene:join:check). Release jars 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 (Maven Central, SHA-1 checked): fail; 10.3.2 and 9.12.3 have no such class. A plain program without the test framework (the real default codec) fails on main and on the 10.6.0 release commit 4ae946e5a9b.

Lucene and upstream sources

Finding record
  • lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-50 (at 645d4fe14a20) (class javadoc: 'childLimitPerParent is the maximum number of child documents to match per parent document'; @lucene.experimental), :60-61 (DEFAULT_CHILD_LIMIT_PER_PARENT = Integer.MAX_VALUE), :170-188 (Weight#explain: scorer(context).iterator().advance(doc) == doc), :313-347 (nextDoc: counts a block's matching children from its first child), :349-382 (advance: childIt.advance(target); on entering a block it resets childDocCount to 0 at :358 and counts the returned child as the first; inside the current block it adds 1 only for the child it returns)
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:136-152 (at 645d4fe14a20) (advance: 'When target > current it behaves as if written: while ((doc = nextDoc()) < target) {} return doc')
  • lucene/join/src/test/org/apache/lucene/search/join/TestParentsChildrenBlockJoinQuery.java:185, (at 645d4fe14a20) 246, 369 ('Do not use Concurrency.INTRA_SEGMENT as that would break childLimitPerParent counting'); testWithRandomizedIndex (expected hits = the first childLimitPerParent matching children of each matching parent, in doc order); testAdvance (advances only to a block's first child or past the end)
The finding in 30 seconds
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-50, :60-61, :170-188, :229, :277-279, :313-347, :349-382, :384-404, :407-416
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:136-155
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ConjunctionDISI.java:132, :161; lucene/core/src/java/org/apache/lucene/search/ConjunctionBulkScorer.java:54
  • a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestParentsChildrenBlockJoinQuery.java:119-129 (expected hits of testWithRandomizedIndex)
Who is affected
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:191-197
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:144-152; lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:27, :35
  • a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestParentsChildrenBlockJoinQuery.java:119-129, :185, :212-219, :246, :336-386, :369, :399
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-50, :191-197
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:408-447
Suggested fix
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:359-363
History
  • a Lucene main checkout lucene/CHANGES.txt:1353 (10.4.0 section from :1298)
  • a Lucene clone 48cf3f515d6 (Decommission branch 10x, #16911, 2026-10-29: deletes the file on branch_10x)
Diagrams
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:313-382

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

Repro files

About the diagrams

Made in parallel with this doc, from the code at 645d4fe14a20 and the executed repro (5 documents, limit 1). The fix panel shows the simplest diff (the nextDoc() loop) because it states the contract in one line; the recommended block-start fix gives the same results.

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.