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.
- Block join. Each parent is indexed right after its children, in one
addDocumentsblock; a parent filter (BitSetProducer) marks which documents are parents. ParentsChildrenBlockJoinQuery(10.4.0, experimental) returns child documents: the children matchingchildQuerywhose parent matchesparentQuery, at mostchildLimitPerParentper parent (defaultInteger.MAX_VALUE). Upstream's randomized test expects the first N matching children of each parent, in doc order.- Iterators. A query's
DocIdSetIteratorhasnextDoc()(the next match) andadvance(target)(the first match at or aftertarget), which must give the same matches as callingnextDoc()untiltarget. Conjunctions, sort skipping andexplain()useadvance().
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.- Index two blocks with
addDocuments: childrena(doc 0) andb(doc 1) under parentp1(doc 2), then childc(doc 3) under parentp2(doc 4). BuildParentsChildrenBlockJoinQuery(parents, type:parent, type:child, childLimitPerParent = 1). - On its own the join matches
aandc, the first matching child of each parent:count(join)= 2. So you expect+join #id:b(join ANDid:b) to return 0 hits, andexplain(join, 1)to say "Not a match". - Instead
count(+join #id:b)= 1 (docb) andexplain(join, 1)reports a match, with assertions on or off, on 10.4.0 through 10.6.0 and main. - Why:
id:bis the cheaper clause, so it leads and asks the join toadvance(1).advance()jumps straight to doc 1 and counts it as p1's first child;nextDoc()would have countedafirst and moved on to p2. - 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.
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.
nextDoc()(:313-347) advances the child iterator only whilechildDocCount < 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), andalignParentAndChildIterator()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.advance(target)(:349-382) callschildIt.advance(target)(:355). If the count is used up or the child is past the current parent (:356), it resetschildDocCount = 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); thenchildDocCount++(:380). The block's matching children between its first child andtargetare 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.Weight#explain(:170-188) testsscorer.iterator().advance(doc) == docon a fresh scorer (:173).- 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'scost(); the join iterator's cost ismin(childCost, parentCost * childLimitPerParent)(:407-416, formula at:414; the scorer supplier'scost()at:229is the same formula).
| call | what the code does | result |
|---|---|---|
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:b | id: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 1 | returns 1 (b) |
explain(join, 1) | the same advance(1) on a fresh scorer | match |
| first block only | the 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 p1 | 0 hits |
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 finitechildLimitPerParent, the parameter the class was added for (#14565). The defaultInteger.MAX_VALUEis 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,
IndexWriterConfigandIndexSearcher; aCheckJoinIndex-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,c1tiny in five blocks; the top hit isc1-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 arec0-0toc0-9and the total stays a lower bound (1001+of 5,000 matches), consistent with skipping still working (the probe does not instrument skipping); IndexSearcher.explainon a non-hit, which reports a match.
- Same root, notes only. As a
MUST_NOTclause 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 -joinreturns 0 hits instead of 1). With an explicitLRUQueryCachethat 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 withsearch()for the query alone. Every extra hit still matcheschildQuery, has a parent that matchesparentQueryand 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()andcount(),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.
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.
/*
* 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());
}
}
}
}
cd $LUCENE && ./gradlew :lucene:join:test --tests TestParentsChildrenChildLimitAdvance -Ptests.seed=5EED1048
- Expected
count(+join #id:b)= 0, because the join alone matches onlyaandc(count(join)= 2).- Observed
count(+join #id:b)= 1:AssertionError: expected:<0> but was:<1>, with assertions on and off.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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)
# 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)DocIdSetIterator#advancemust behave like anextDoc()loop to the target;nextDoc()never returnsb,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
BooleanQueryto 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: "Whentarget > currentit behaves as if written:while ((doc = nextDoc()) < target) {}return doc;" (DocIdSetIterator.java:144-152). With limit 1,nextDoc()never returns doc 1, butadvance(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:bis the intersection of{a, c}and{b}: empty. - Upstream's definition of the hits.
testWithRandomizedIndexadds a child to the expected set only whilematchingChildrenCount <= 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 withtitle:milk(child3, child5) "the boolean query will return no match"; it proposes putting achildQueryinside 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:185intestAdvance,:246in the shared helpertest(...),:369intestExplain).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.
testAdvanceadvances only to a block's first child or past the end (TestParentsChildrenBlockJoinQuery.java:212-219);testExplain'sexplain(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 runQueryUtils.check, whosecheckFirstSkipToadvances 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.
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.
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
childLimitPerParentcounted children before it moves on to the next parent; blocks before the target's block are still skipped with oneadvance. - 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 callsparentIt.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/joinmodule 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-0toc0-9, and its1001+total is consistent with skipping still active. - Not run: the full Lucene suite, nightly tests, benchmarks.
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/joinmodule: 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.
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.
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.
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; readingadvance()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 freshadvanceto every target,nextDocthenadvance,advancechains, 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).
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 intestExplain; 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 forToParentBlockJoinQuery, design only), LUCENE-3600 and #6935 (LUCENE-5873), olderadvanceassertions 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
716d6f216f5keeps the sameadvance(). - Gaps: keyword search only; GitHub search does not index PR diffs (recent PRs were checked by their file lists); private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-29 | 16 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-27 | only #14728, #14565, #15041 and old unrelated block-join items; no report |
| Threads read in full | 2026-10-29 | #14728 (conversation, reviews, review comments), #14565, #15041, #14975, #15435 | no discussion of advance() into a block, conjunctions or explain over the limit |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-29 | 20 queries | only GitHub mirror and commit mails for the items above |
| JIRA archive, downstream projects, web | 2026-10-29 | 4 JIRA queries; Elasticsearch, OpenSearch, Solr and GitHub-wide code search; 6 web queries | no report; no downstream use of the class |
| GitHub refresh | 2026-10-30 | class name and childLimitPerParent over issues and PRs; the 8 PRs updated since 2026-10-29; the file's upstream history | nothing new; not fixed |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails, assertions on and off (gradle and standalone runner) |
10.6.0 (released, 4ae946e5a9b) | affected | minimal test fails, assertions on and off (release jars); the earlier test too |
| 10.5.2 | affected | minimal test fails, assertions on and off (release jars); the earlier test too |
| 10.5.0, 10.5.1 | affected | minimal test fails, assertions on and off (release jars); the earlier test fails with assertions on |
| 10.4.0 (first release with the class) | affected | minimal 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-level | the earlier test: unchanged fails; with the fix passes |
| 10.3.2, 9.12.3 | no class | the class does not exist; the earlier test does not compile |
fixes on 645d4fe14a20 | not affected | the minimal test passes with either fix; lucene/join: 105 tests, 0 failures |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (judge unit, bounded-exhaustive check) | candidate | 11,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 | real | Own 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; claim | Stock 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 applied | Re-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 applied | Read 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 575 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,285 words
- 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.javais byte-identical at upstream main716d6f216f5, 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 of645d4fe14a20: the minimal test and the wholelucene/joinmodule (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 commit4ae946e5a9b.
- 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-416a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:136-155a 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:54a 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, :35a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestParentsChildrenBlockJoinQuery.java:119-129, :185, :212-219, :246, :336-386, :369, :399a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-50, :191-197a 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
- TestParentsChildrenChildLimitAdvance.java (one test, public API):
repro/LFV-1048/TestParentsChildrenChildLimitAdvance.java(73 lines)
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.
assets/LFV-1048/bug.png(575 KB)
