Since #16669, Lucene runs MUST x, MUST_NOT y by handing x a per-window set of allowed documents. A scoring block join applies that set to its children, not to the parents it returns.
- Block join. Each parent is indexed right after its children, in one
addDocumentsblock.ToParentBlockJoinQuerymatches children and returns their parents; with ScoreMode Avg, Max, Min or Total the parent's score comes from its matching children. - Bulk scoring. A
BulkScorerscores a range of doc IDs and receivesacceptDocs, "the allowed documents to match". Until 10.6 that was always the live (not deleted) documents, and a valid block-join index deletes whole blocks. - Since #16669.
ReqExclBulkScorer, which runsMUST x, MUST_NOT y, works in 4,096-document windows and passes "live and not matching y" to x's bulk scorer asacceptDocs.
- Index two parent/child blocks with default settings: docs 0-1 are children of p1 (doc 2, visible), docs 3-4 are children of p2 (doc 5,
status:hidden). Docs 0 and 3 haveskill:java. - Search
+ToParentBlockJoinQuery(skill:java, ScoreMode.Avg) -status:hiddenand expect[p1].count()of the same query returns 1, and 10.5.2 returns[p1]. - Instead
search(q, 10)returns[p1, p2]on main and on 10.6.0, with assertions on or off, for Avg, Max, Min and Total. - Why: since #16669 the
MUST_NOTmatches reach the join as its set of allowed documents (all but doc 5). The join hands that set to its child scorer, which checks children 0 and 3, both allowed. Parent doc 5 is never checked. - When a block straddles doc 4,096 (6,000 docs in blocks of 3) and its child before the boundary matches, the child check falls below the 4,096-doc window and the search throws:
AssertionErrorwith-ea,ArrayIndexOutOfBoundsException: Index -1with-da. With no matching child there, the excluded parents come back silently instead.
Users of 10.6.0 (released 2026-10-29) or main who run a scoring nested search (Avg, Max, Min or Total) as the only required clause next to a MUST_NOT clause or a pure-negative filter such as FILTER(*:* -acl:deny), once the join's child query matches more than about 0.3% of a segment's documents. Default codec and searcher; no deletions needed.
Mechanism, terms and numbers647 words
Since #16669 (main and branch_10_6, listed under 10.6.0), ReqExclBulkScorer runs MUST x, MUST_NOT y in 4,096-document windows and passes "live and not matching y" to x's bulk scorer as acceptDocs. When x is a ToParentBlockJoinQuery with ScoreMode Avg, Max, Min or Total, its BlockJoinBulkScorer forwards that set unchanged to the child scorer (ToParentBlockJoinQuery.java:541, since #13697) and never checks the parents it returns. So a parent excluded by MUST_NOT comes back whenever one of its matching children is allowed ([p1, p2] instead of [p1] in a 6-document repro, while count() says 1), a MUST_NOT that matches only children removes parents, and a block that straddles a window boundary, with a matching child before it, makes the search throw. Default codec and searcher, no deletions needed; every release through 10.5.2 is correct (five run, the others by a source scan).
- Which scorer runs. With a
MUST_NOTclause and a required side costing more than 0.3% of the segment, and exactly one MUST clause and no SHOULD clause,BooleanScorerSuppliertakes that clause's own bulk scorer and wraps it inReqExclBulkScorer(BooleanScorerSupplier.java:203-207,:235-236,:256-265). - What it passes down. Per window
[windowMin, windowMin + 4096)it loads theMUST_NOTmatches into a bit set, flips it, ANDs in the live docs and callsreq.score(collector, windowBits, windowMin, windowMax)(ReqExclBulkScorer.java:59-86).WindowBits.getanswers only inside its window:assert index >= windowBase && index < windowEnd(:113-116), although itslength()reports the live docs' length, or the caller'smaxwhen there are no deletions, not the window end (:85,:119-121). - What the join does with it. A scoring
ToParentBlockJoinQueryusesBlockJoinBulkScorer(ToParentBlockJoinQuery.java:166-174; ScoreMode.None uses the default bulk scorer). Itsscoremaps the parent range to the child range[prevParent + 1, lastParent + 1)and callschildBulkScorer.score(wrappedCollector, acceptDocs, prevParent + 1, lastParent + 1)(:533-541). The wrapping collector emits a parent when the next block starts or the batch ends (:586-591,:609-612); nothing tests the parent againstacceptDocs.
- Six documents: children 0-1 and parent p1 (doc 2), children 3-4 and parent p2 (doc 5,
status:hidden);skill:javaon docs 0 and 3. - Window
[0, 4096): the bit set allows docs 0-4 and rejects doc 5. The child scorer asks about docs 0 and 3, both allowed, so p1 and p2 are collected. Doc 5 is never asked about. - A
MUST_NOT skill:java(children only) rejects docs 0 and 3 instead, so both parents disappear:[]instead of[p1, p2].
In a 6,000-document segment of 3-document blocks whose children all match skill:java, the block of docs 4,095-4,097 straddles the window boundary. In the window [4096, 8192), prevParent is 4,094, so the child range starts at 4,095, one below the window. The child scorer asks acceptDocs only about the children it matches (DocAndFloatFeatureBuffer.java:57); doc 4,095 matches, so WindowBits.get(4095) fails its assertion with -ea, and with -da reads mask bit -1 (ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 64). With only doc 4,095 changed to skill:go, the block still straddles the boundary but nothing below the window is asked: the search returns 2,000 hits, the 1,000 hidden parents included, where count() says 1,000. In large random segments with 2% of children matching, 18 of 20 layouts returned the excluded parents without an exception and 2 threw; with 50% matching, all 20 threw.
count(), ConstantScoreQuery and ScoreMode.None build the join with ScoreMode.None (ToParentBlockJoinQuery.java:98), whose default bulk scorer tests acceptDocs on the documents it collects, the parents (Weight.java:296-298). Before #16669, ReqExclBulkScorer passed only the live docs, which agree on children and parents in a valid block-join index, so the forwarding added by #13697 was harmless.
Exposure and likelihood348 words
- Trigger. A scoring join that, after rewrite, is the only required clause, with no SHOULD clause; a
MUST_NOTclause, or a pure-negative filter such asFILTER(*:* -acl:deny), whichBooleanQuery.rewriteturns into aMUST_NOT(it drops the*:*FILTER,BooleanQuery.java:387-392, and inlines the rest,:530-560); a join cost above 0.3% of the segment'smaxDoc(BooleanScorerSupplier.java:205; the join's cost is its child query's cost,ToParentBlockJoinQuery.java:177-180; the threshold exists only since #16715, and at #16669's own commit any join cost took the bulk path); a search that needs scores, such as the defaultsearch(q, n). Default codec,IndexWriterConfigandIndexSearcher, aCheckJoinIndex-valid index, no deletions. - Not affected:
count(),ConstantScoreQuery,ScoreMode.Noneand a positive FILTER next to the join (executed); a join used as FILTER (built without scores, so ScoreMode.None) and a second MUST clause next to the join (a conjunction scorer runs instead), by code reading;ToChildBlockJoinQuery(no custom bulk scorer; itsMUSTjoin plusMUST_NOTchecks in the bounded-exhaustive model, up to 7 documents (8 without deletions), show no mismatch). - Effect. Silent wrong results: excluded parents come back with scores and can be the top hit (a
search(q, 10)test failed 10 of 10 randomized iterations, each time at an excluded parent that ranks first because all scores tie). At scale: 300 blocks in 6 segments, query cache on, returned 260 parents per scoring mode, 89 of them excluded;count()gave the correct 171. Where a block straddles a 4,096-document window boundary and one of its children before the boundary matches the child query, the request fails instead; without such a match it returns the wrong parents silently. A child-onlyMUST_NOTremoves parents or changes their scores (p1's Total score halved in one test). - Versions. 10.6.0 (released 2026-10-29; the RC1 bits), main and branch_10_6; branch_10x has the same code by reading.
- Likelihood: plausible. A natural nested-search shape, deterministic once the join is not very selective; a positive filter or second MUST clause next to the join, or ScoreMode.None, avoids it.
- Not run: concurrent (executor) search, intra-segment partitions, branch_10x builds.
One JUnit test on the public API, in Lucene's join test package: two blocks (6 documents) in one segment, default codec, query cache off. It asserts count(q) == 1, which passes, and that search(q, 10) returns only p1, which fails.
/*
* 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.ArrayList;
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.ScoreDoc;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestBlockJoinMustNotExcludesParent extends LuceneTestCase {
public void testMustNotExcludesParent() throws Exception {
try (Directory dir = newDirectory()) {
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
try (IndexWriter w = new IndexWriter(dir, iwc)) {
// docs 0,1 = children, doc 2 = parent p1 (visible)
// docs 3,4 = children, doc 5 = parent p2 (hidden)
for (String[] p : new String[][] {{"p1", "visible", "python"}, {"p2", "hidden", "go"}}) {
List<Document> block = new ArrayList<>();
for (String skill : new String[] {"java", p[2]}) {
Document child = new Document();
child.add(new StringField("skill", skill, Field.Store.NO));
block.add(child);
}
Document parent = new Document();
parent.add(new StringField("type", "parent", Field.Store.NO));
parent.add(new StringField("id", p[0], Field.Store.YES));
parent.add(new StringField("status", p[1], Field.Store.NO));
block.add(parent);
w.addDocuments(block);
}
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setQueryCache(null);
BitSetProducer parents = new QueryBitSetProducer(new TermQuery(new Term("type", "parent")));
CheckJoinIndex.check(reader, parents);
Query join =
new ToParentBlockJoinQuery(
new TermQuery(new Term("skill", "java")), parents, ScoreMode.Avg);
Query q =
new BooleanQuery.Builder()
.add(join, Occur.MUST)
.add(new TermQuery(new Term("status", "hidden")), Occur.MUST_NOT)
.build();
assertEquals(1, searcher.count(q)); // the non-scoring path excludes p2
List<String> ids = new ArrayList<>();
for (ScoreDoc sd : searcher.search(q, 10).scoreDocs) {
ids.add(searcher.storedFields().document(sd.doc).get("id"));
}
assertEquals(List.of("p1"), ids);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:join:test --tests TestBlockJoinMustNotExcludesParent -Ptests.seed=5EED0222
- Expected
search(q, 10)returns[p1], matchingcount(q)= 1 and Lucene 10.5.2.- Observed
search(q, 10)returns[p1, p2]:AssertionError: expected:<[p1]> but was:<[p1, p2]>, with assertions on and off.
More files, environment, full output251 words
The test pins the default codec (the test framework randomizes it) and turns the query cache off (the framework randomizes caching), checks the index with CheckJoinIndex, and runs +ToParentBlockJoinQuery(skill:java, ScoreMode.Avg) -status:hidden. The seed only picks the framework's random directory, locale and settings, which the test does not depend on.
The same root cause has two more symptoms, shown by the hunt's three-test class (not needed to file): a MUST_NOT skill:java that matches only children returns [] instead of [p1, p2], and a 6,000-document segment whose children all match and whose block of docs 4,095-4,097 straddles doc 4,096 throws AssertionError at ReqExclBulkScorer$WindowBits.get(ReqExclBulkScorer.java:114) with -ea and ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 64 with -da (main 645d4fe14a20).
Regression test: this same test; it passes with the recommended fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT) and 10.6.0 RC1 4ae946e5a9b (branch_10_6): fail; 10.5.2 release jars: pass |
|---|---|
| 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/TestBlockJoinMustNotExcludesParent.java $LUCENE/lucene/join/src/test/org/apache/lucene/search/join/
cd $LUCENE && ./gradlew :lucene:join:test --tests TestBlockJoinMustNotExcludesParent -Ptests.seed=5EED0222
# assertions off: add -Ptests.asserts=false (same failure)
# main 645d4fe14a20, gradle, -Ptests.seed=5EED0222 (the same with -Ptests.asserts=false)
TestBlockJoinMustNotExcludesParent > testMustNotExcludesParent FAILED
java.lang.AssertionError: expected:<[p1]> but was:<[p1, p2]>
...
at org.apache.lucene.search.join.TestBlockJoinMustNotExcludesParent.testMustNotExcludesParent(TestBlockJoinMustNotExcludesParent.java:81)
:lucene:join:test (FAILURE): 1 test, 1 failure
# standalone runner: main 645d4fe14a20 -ea / -da: fail 1/1; 10.6.0 RC1 jars -ea / -da: fail 1/1 (same message); 10.5.2: pass 1/1
# with the fix: the test passes; :lucene:join:test (SUCCESS): 105 tests, 3 skipped- The class's own comment: "acceptDocs applies (and is checked) only in the parent document space" (
ToParentBlockJoinQuery.java:141-142). count(),ScoreMode.Noneand 10.5.2 return only p1;MUST_NOTclauses "must not appear in the matching documents".- #14067 was told checking children is fine because deletions cover whole blocks; since #16669
acceptDocsalso carriesMUST_NOTmatches, which do not.
The full argument311 words
- The class's own rule. "NOTE: acceptDocs applies (and is checked) only in the parent document space" (
ToParentBlockJoinQuery.java:141-142, from 2017). The class javadoc: "You can then use this Query as a clause with other queries in the parent document space" (:56-57); the join package doc: "The resulting query can then be used as a clause in any query that matches parent" (package-info.java:37-38). - The contracts.
BulkScorer.score:acceptDocs"represents the allowed documents to match" (BulkScorer.java:70-71), and this scorer's matches are parents.Occur.MUST_NOT: clauses that "must not appear in the matching documents ... do not contribute to the score of documents" (BooleanClause.java:58-60); the parent-level case returns documentsacceptDocsrejects, and in the child-only case a clause that matches no parent still removes parents and changes their scores. - Consistency. The same query gives the right set through
count(),ConstantScoreQueryandScoreMode.None, and on every release through 10.5.2. - The crash.
WindowBits.length()reports the full length, butgetanswers only inside its window (ReqExclBulkScorer.java:113-121), so a lookup thatBitsallows asserts or reads index -1. - Precedent. LUCENE-6588 (#7646, 2015, reported with a patch by Christoph Kaser):
ToChildBlockJoinQueryskipped a parent's score when the first child of a block was not inacceptDocs, anacceptDocsthat was not block-consistent. jpountz first replied "I don't think we should try to support fine-grained deletion of children documents", then, reminded that theacceptDocscame from a filter, committed the fix as a bug ("I had just forgotten that some child documents could seem deleted because of FilteredQuery").TestBlockJoin.testParentScoringBugstill says "delete documents to simulate FilteredQuery applying a filter as acceptDocs" (TestBlockJoin.java:1539-1541). - No test expects it.
TestBlockJoinusesMUST_NOTonly inside the query a join wraps, never as a clause next to a join;TestReqExclBulkScorerhas no join; the #16669 discussion never mentions joins.
The devil's-advocate check148 words
The strongest case: in #14067 (2024), jpountz wrote "Block-joins require deletes to be consistent across parent and child documents. So I wouldn't call it a bug, the implementation should be free to check deletes on child documents or parent documents depending on whichever is more convenient/efficient." So checking children was an accepted design; the join is @lucene.experimental; and a child-only MUST_NOT at the parent level is odd input.
Why it fails: that reply assumed acceptDocs are deletions, which are block-consistent. Since #16669 they also carry MUST_NOT matches, which are not; the BulkScorer javadoc never limited acceptDocs to live docs, the class's own comment says it is checked in parent space, and in LUCENE-6588 the same objection (per-child deletions are unsupported) was dropped once it was clear that a filter applied as acceptDocs need not be block-consistent. @lucene.experimental is about API stability, not wrong results. Verdict: not intended.
Recommended Check acceptDocs on parents in BlockJoinBulkScorer (validated)
In BlockJoinBulkScorer.score, pass null acceptDocs to the child scorer and test acceptDocs on each parent before collecting it (+6/-5 lines, essentially the change proposed in #14067). The repro, the window-straddle crash case and all 105 lucene/join tests pass with it.
// BlockJoinBulkScorer.score
BatchAwareLeafCollector wrappedCollector = wrapCollector(collector, acceptDocs);
childBulkScorer.score(wrappedCollector, null, prevParent + 1, lastParent + 1);
// wrapCollector(LeafCollector collector, Bits acceptDocs): in collect() and endBatch()
if (currentParent >= 0 && (acceptDocs == null || acceptDocs.get(currentParent))) {
in.collect(currentParent);
}All options and trade-offs3 options · 419 words
Option A was built and run; B and C are untested alternatives.
In ToParentBlockJoinQuery.BlockJoinBulkScorer, give the child scorer null acceptDocs and pass acceptDocs to the wrapping collector, which tests each parent before in.collect(currentParent) (in collect and in endBatch). Every parent collected lies in [min, max), so the lookup stays inside the WindowBits window.
// BlockJoinBulkScorer.score
BatchAwareLeafCollector wrappedCollector = wrapCollector(collector, acceptDocs);
childBulkScorer.score(wrappedCollector, null, prevParent + 1, lastParent + 1);
// wrapCollector(LeafCollector collector, Bits acceptDocs): in collect() and endBatch()
if (currentParent >= 0 && (acceptDocs == null || acceptDocs.get(currentParent))) {
in.collect(currentParent);
}Trade-offs
- Children of excluded parents are still scored before the parent is dropped, so #16669's push-down does not help this query; no benchmark was run.
- The child scorer no longer sees the live docs: in an index whose deletions are not whole blocks (unsupported, per #14067) deleted children would count, as they already do on the non-scoring path, whose default bulk scorer checks only the parent (
Weight.java:296-298, by code reading).
What the checks validated
- Scratch worktree of
645d4fe14a20, JDK 25: the minimal test fails on stock and passes with the fix, assertions on and off. - The hunt's three tests, including the 6,000-document crash case, pass with the fix (assertions 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 -x test(format, ecjLint, forbiddenApis) passes. - The bounded-exhaustive model with the fix: 0 mismatches for the ToParent family on every layout up to 5 documents (248,936 checks; stock: only the
MUST_NOTmismatches). Earlier: an independent verifier's own fix and the hunt's other repros pass; 9 upstream join test classes (45 tests) pass. - Not run: the full Lucene suite, nightly tests, benchmarks.
Pass the child scorer a Bits that accepts a child when its parent, parents.nextSetBit(child), is accepted. Every such parent lies inside the window.
Trade-offs
Keeps skipping children of excluded parents; costs a nextSetBit per lookup; the same deleted-child semantics as A. Not built.
Pass only the caller's acceptDocs to the required clause and apply the window's exclusion mask to what it collects, through a collector wrapper.
Trade-offs
Could address any bulk scorer that maps doc spaces (not built or validated), but gives up #16669's push-down for every query. Making WindowBits answer below its window would remove only the crash, not the wrong results (by code reading).
Applications that hide parent documents with a MUST_NOT rule or a pure-negative filter next to a scoring nested query get those parents back: in a 300-block test, 89 of the 260 parents returned were ones the rule excluded.
An automated bounded-exhaustive check ran every valid single-segment block layout up to 7 documents (8 without deletions), every match set and score mode, through the real queries against a brute-force join (83 million checks). For ToParentBlockJoinQuery only "join MUST plus one MUST_NOT document" failed, in the four scoring modes; the code then showed the cause.
The method in full171 words
Method: bounded-exhaustive translation validation of the block-join queries (BlockJoinTV), on the stock main jar.
- Every
CheckJoinIndex-valid single-segment block layout up to 7 documents with whole-block deletions (79,338,044 checks, assertions on and off) and up to 8 without deletions (3,773,664 checks), times every child and parent match set, every ScoreMode and a plain or two-phase child iterator, against an independent brute-force join. - The only failing kind for
ToParentBlockJoinQuerywas check C8 (BooleanQuery MUST join + MUST_NOT one document): 20,026 result mismatches per scoring ScoreMode, plus score mismatches; ScoreMode.None was clean. 5 seeded mutants were all killed. The same runs also flag other join queries (ToChildBlockJoinQuery,ParentChildrenBlockJoinQueryandParentsChildrenBlockJoinQuery, in checks C4-C7); those are not this bug. - The root cause was then read from
ReqExclBulkScorer; the window-straddle crash was found by reasoning about its windows and confirmed by a test. A second hunt unit, modellingReqExclBulkScorerand scanning the in-tree bulk scorers, found the same bug independently at about the same time (merged into this record).
Partly known: closed, unmerged #14067 (2024) proposed essentially the same code change as a deletions cleanup, and a committer replied it was not a bug while deletions cover whole blocks. Nobody has reported the wrong results or the crash, which exist only since #16669. Searched 2026-10-26, refreshed 2026-10-29.
Searches6 searches
- #14067 (jimczi, opened 2024-12-15; labelled stale by a bot on 2024-12-31, closed unmerged by its author on 2025-03-21, no reason given), "BlockJoinBulkScorer could check for parent deletions (not children)" (first titled "must"): the same defective call and essentially fix A, with a test whose
acceptDocsmarks only parents. jpountz replied they "wouldn't call it a bug" because deletions are consistent across a block; ChrisHegarty suggested asserting that constraint. Nobody mentionedMUST_NOT,ReqExclBulkScorer, wrong BooleanQuery results or a crash. Under the hunt's rule a partial overlap counts as known. - Context, not reports: #16669/#16668 (the trigger; its discussion never mentions joins), #13697/#13764 (the forwarding), #16715/#16702 (narrows the trigger), #7646 (LUCENE-6588, precedent), #16885/#16886/#16887 (other block-awareness bugs, different code).
- Not fixed upstream: main head
645d4fe14a20on 2026-10-29; no commit after #16669 touchesReqExclBulkScorer.javaon main or branch_10_6, andToParentBlockJoinQuery.javalast changed before it. - Gaps: private channels and private downstream trackers cannot be searched; GitHub search treats multi-word queries roughly as phrases.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | 16 queries (BlockJoinBulkScorer / ToParentBlockJoinQuery / ReqExclBulkScorer x acceptDocs, WindowBits, MUST_NOT, ArrayIndexOutOfBoundsException, class names); all 379 issues and PRs updated since 2026-09-20; the changed files of all 413 open PRs | #14067 (partial overlap); no report of the bug |
| Threads read in full | 2026-10-26 | #14067, #16669/#16668, #13697, #16702/#16715 and their timelines | no mention of MUST_NOT with joins |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 20 queries; dev@ 10.6.0 release threads | no report |
| JIRA archive, downstream repositories, web | 2026-10-26 | 4 JIRA, 2 downstream code and 6 web queries; LUCENE-6588, LUCENE-6636, LUCENE-6553, LUCENE-6512 read | no report; LUCENE-6588 is precedent |
| GitHub refresh | 2026-10-29 | 7 keyword searches over issues and PRs updated since 2026-10-20/25; main and branch_10_6 file histories | nothing new; not fixed |
| GitHub, threads saved | 2026-10-29 | read-only API reads of #14067 and #7646 (issue, comments, events), main head and the releases/lucene/10.6.0 tag | quotes as cited; main still 645d4fe14a20; no 10.6.0 tag at the time (10.6.0 was released on 2026-10-29) |
Introduced by #16669 (merged 2026-09-24 on main, backported to branch_10x and branch_10_6) on top of #13697's forwarding of acceptDocs (9.12.0). Bisected: its parent passes, it fails. Fails on main 645d4fe14a20 and 10.6.0 (released 2026-10-29; RC1 jars); 9.11.1, 9.12.0, 9.12.3, 10.0.0 and 10.5.2 pass, and no other release tag has the windowed code.
Versions tested9 rows
Introduced by #16669 "Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions" (kkewwei, for #16668; merged by romseygeek 2026-09-24): c5fa32ce49f on main, 92ce3eaf4bf on branch_10x the same day, contained in branch_10_6; listed under 10.6.0 in CHANGES.txt:687. Relies on #13697 "Add Bulk Scorer For ToParentBlockJoinQuery" (45da83bd723, 2024-09-11; 9.x backport #13764, first shipped in 9.12.0), which added the forwarding at ToParentBlockJoinQuery.java:541. Narrowed, not fixed, by #16715 (2026-10-08/09): joins costing at most 0.3% of maxDoc take the non-bulk ReqExclScorer. 10.6.0 was released on 2026-10-29: the releases/lucene/10.6.0 tag points at the RC1 revision 4ae946e5a9b.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails 1/1 and the hunt's tests 3/3, assertions on and off |
10.6.0 RC1 4ae946e5a9b (branch_10_6) | affected | minimal test fails 1/1 and the hunt's tests 3/3, assertions on and off |
branch_10_6 head 2763db11b0a (2026-10-26) | affected | hunt's tests fail 3/3, assertions on and off |
#16669 c5fa32ce49f vs its parent c80c9f53674 | bisect | parent passes 3/3; #16669 fails 3/3, assertions on and off |
| main with only ReqExclBulkScorer reverted to its pre-#16669 source | not affected | 3/3 pass |
| 9.12.0, 9.12.3, 10.0.0, 10.5.2 | not affected | pass; a path probe shows both ReqExclBulkScorer and BlockJoinBulkScorer on the stack |
| 9.11.1 | not affected | pass (no BlockJoinBulkScorer yet) |
| 9.12.1, 9.12.2, 10.1.0 - 10.5.1 | read | not run; a scan of every 9.11, 9.12 and 10.x release tag through 10.5.2 finds no WindowBits |
fix A on 645d4fe14a20 | not affected | the test passes; lucene/join 105 tests, 0 failures |
Reproduced by an independent verifier, a devil's-advocate verifier, an adjudication and a cross-model root-cause review (OpenAI GPT-5.6). A second cross-model review that could not run code voted not real, while its own saved test runs reproduce the bug.
Checks and verdicts227 words
Each check ran its own test or re-ran an earlier one on stock main; the controls (10.5.2, the fix) pass.
| Check | Verdict | What it found |
|---|---|---|
| Finder (bounded-exhaustive check) | candidate | 79,338,044 checks up to 7 documents; for ToParentBlockJoinQuery only MUST join + MUST_NOT one document fails (20,026 mismatches per scoring mode); JUnit 3/3 fails with -ea and -da; the fix passes. |
| Cross-model review, root-cause lens (OpenAI GPT-5.6) | real (high) | Own test 3/3 fails, passes on 10.5.2; same first wrong state (ToParentBlockJoinQuery.java:541); the fix passes four upstream join suites. |
| Cross-model review, refute lens (OpenAI GPT-5.6) | not real (low), procedural | Its later sessions could not run commands; its own preserved test fails on main and passes on 10.5.2. |
| Independent reproducer | real | Own test 3/3 fails with -ea and -da; passes on 10.5.2, 9.12.3 and with its own fix. |
| Works-as-designed lens (OpenAI GPT-5.6, reading only) | real (medium) | Intended-behaviour argument weak; a separate re-run with -da fails 3/3. |
| Intended-behaviour devil's advocate | real | The hunt's test fails; its own 300-block, 6-segment oracle returns 89 excluded parents. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test with a fresh seed and the hunt's test: fail; 10.5.2 and the fix pass. |
| Re-verification and this doc's runs | still reproduces (2026-10-29) | Main 645d4fe14a20 and 10.6.0 RC1: fail; 10.5.2 and the fix: pass. |
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 564 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,518 words
- Registry title
- A MUST_NOT clause next to a scoring ToParentBlockJoinQuery is checked against child documents instead of parents, so excluded parents are returned, and the search throws when a parent/child block straddles a 4096-doc window
- Area
- join / block join (ToParentBlockJoinQuery.BlockJoinBulkScorer) x search / BooleanQuery MUST_NOT bulk scoring (ReqExclBulkScorer, #16669)
- Priority
- High, rank 4: Shipped in 10.6.0, a scoring ToParentBlockJoinQuery next to a MUST_NOT silently returns the excluded parents, or throws, with default settings.
- Impact class
- silent wrong results
- Impact note
- Applications on 10.6.0 (released 2026-10-29), unreleased main (11.0.0-SNAPSHOT) or a branch_10_6 / branch_10x build that run a scoring ToParentBlockJoinQuery (ScoreMode Avg, Max, Min or Total) as the only required clause of a BooleanQuery with a MUST_NOT clause (also a pure-negative filter such as FILTER(: -acl:deny), which BooleanQuery.rewrite turns into one) get back parents the MUST_NOT clause excludes, with the default codec, IndexSearcher and IndexWriterConfig, -ea or -da, on any CheckJoinIndex-valid index once the join's cost exceeds 0.3% of the segment's maxDoc; on segments where a block straddles a 4096-doc window the same search throws instead (AssertionError with -ea, ArrayIndexOutOfBoundsException index -1 with -da), and a MUST_NOT clause that matches only children removes parents or changes their scores; count(), ConstantScoreQuery, ScoreMode.None, a FILTER join and a positive FILTER or MUST clause next to the join are not affected, and no earlier release (through 10.5.2) is affected.
- Summary
- You index parent/child blocks and search BooleanQuery(MUST ToParentBlockJoinQuery(skill:java, parentsFilter, ScoreMode.Avg), MUST_NOT status:hidden), expecting only the parents that are not hidden, which is what count() of the same query and ScoreMode.None return. Instead, on main the hidden parent p2 comes back for Avg, Max, Min and Total ([p1, p2] instead of [p1]); a MUST_NOT clause that matches only child documents (MUST_NOT skill:java) removes both parents ([] instead of [p1, p2]); and in a 6,000-doc segment where a block straddles doc 4096 the search throws AssertionError in ReqExclBulkScorer$WindowBits.get with -ea and ArrayIndexOutOfBoundsException (index -1) with -da. The cause is that since #16669 ReqExclBulkScorer folds the MUST_NOT matches of each 4096-doc window into a bit set that it passes to the required clause as acceptDocs, and the join's BlockJoinBulkScorer forwards that acceptDocs unchanged to its child scorer (ToParentBlockJoinQuery.java:541), so the exclusions are tested on child doc IDs, some of them below the window, and never on the parents it collects.
- Introduced
- c5fa32ce49f6d28e82ac1b6f111264d955d54a7f on main (apache/lucene#16669 'Use intoBitSet to speed up ReqExclBulkScorer with dense exclusions', by kkewwei for issue #16668, merged by romseygeek 2026-09-24), backported the same day to branch_10x as 92ce3eaf4bf524ee24d7dc742cedeaa812695db8, which branch_10_6 contains [exec: the follow-up bisection on snapshot jars built from clean checkouts passes 3/3 at the parent c80c9f53674 and fails 3/3 at c5fa32ce49f, -ea and -da; replacing only ReqExclBulkScorer on main with its c80c9f53674 source makes all 3 tests pass; read: git log -G WindowBits and git blame of ReqExclBulkScorer.java:86 and :112-116 return only c5fa32ce49f]. Latent precondition, harmless until then: 45da83bd723f4129340322aed4cc84fdf1f194cd (apache/lucene#13697 'Add Bulk Scorer For ToParentBlockJoinQuery', 2024-09-11; 9.x backport #13764, 4d379db1a82, first shipped in 9.12.0 and 10.0.0) added BlockJoinBulkScorer forwarding acceptDocs to the child scorer (ToParentBlockJoinQuery.java:541), which could not be observed while acceptDocs was always the live docs and valid block-join indexes delete whole blocks [exec: 9.12.0 to 10.5.2 run this query through both ReqExclBulkScorer and BlockJoinBulkScorer and return the right parents] (2026-09-24 (c5fa32ce49f on main; 92ce3eaf4bf on branch_10x and branch_10_6); latent precondition 2024-09-11 (45da83bd723, #13697))
- First affected version
- 10.6.0 (released 2026-10-29, the first release with it, then 11.0.0; CHANGES.txt lists #16669 under 10.6.0) [exec: 9.11.1, 9.12.0, 9.12.3, 10.0.0 and 10.5.2 pass; read: no 9.11 to 10.5 release tag contains WindowBits]
- Affected versions
- main (11.0.0-SNAPSHOT) from c5fa32ce49f (2026-09-24) through 645d4fe14a2 (upstream main head, 2026-10-29) [exec: c5fa32ce49f, ea57360912ed and 645d4fe14a2 fail 3/3 with -ea and -da]; 10.6.0 (released 2026-10-29) [exec: jars built at the RC1 revision 4ae946e5a9b fail 3 of 3 with -ea and -da]; branch_10_6 (10.6.0-SNAPSHOT) [exec: head 2763db11b0a fails 3/3 with -ea and -da, with the same three symptoms]; branch_10x [read: same ReqExclBulkScorer and ToParentBlockJoinQuery.java:541, not built]; not affected: every release through 10.5.2 [exec: 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 pass, with a path probe showing both scorers on the stack from 9.12.0 on; read: tag scan] and branch_10_5 [read: no #16669]; #16715 (2026-10-08/09, on main and branch_10_6) narrows the trigger to joins whose cost exceeds 3*maxDoc/1000 but does not fix it
- 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 and a prior-report search; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- known (partial overlap): apache/lucene#14067 'BlockJoinBulkScorer could check for parent deletions (not children)' (first titled '... must check ...'; jimczi, opened 2024-12-15, closed unmerged as stale 2025-03-21) proposed the same code change in BlockJoinBulkScorer (pass null acceptDocs to the child scorer and check acceptDocs on each parent), with a test whose acceptDocs marks only parents, framed as a deletions issue; jpountz replied that block joins require deletes to be consistent across parent and children, so they 'wouldn't call it a bug' and the implementation may check either side, and ChrisHegarty suggested asserting that constraint; nobody mentioned MUST_NOT, ReqExclBulkScorer, wrong BooleanQuery results or a crash, which became reachable only with #16669 (2026-09-24, whose discussion never mentions joins). No issue, PR, list post or downstream report of the bug itself was found (follow-up prior-report record verdict partially_known, searched 2026-10-26: a scripted prior-report search with 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries, 336 tool hits screened; read-only GitHub API reads of #14067, #16669/#16668, #13697, #16702/#16715 and their timelines, the changed files of all 413 open apache/lucene PRs, and all 379 apache/lucene issues and PRs updated since 2026-09-20; downstream repository searches; the finder's 15-query and the candidate's 25-query earlier runs). Not fixed upstream: main head 645d4fe14a2 still fails (re-run 2026-10-29), no later commit touches ReqExclBulkScorer.java or ToParentBlockJoinQuery.java on main, branch_10x or branch_10_6, and no open PR changes the acceptDocs forwarding (#13770 and #12194 touch the files but not this). Related, not reports: #16715/#16702 (narrows the trigger), #16307 and #14062 (earlier ReqExclBulkScorer changes), #7646 (LUCENE-6588, precedent that a non-deletion acceptDocs applied in the wrong block-join space is a bug), #16885 and #16886 (closed 2026-10-26, fixed by #16893: ffa9d816eeb on main, afeeeff2d14 on branch_10_6, not on branch_10x as of 657014b3c57) and #16887 (other block-awareness bug, different code; closed 2026-10-30, fixed by #16947 (romseygeek; merged 2026-10-30 as f6e64aa0db3 on main and e5c8f72f00f on branch_11x; not on branch_10_6 or in 10.6.0)). Gaps: private channels (security@, private@) and private downstream trackers cannot be searched; GitHub search phrase-matches multi-word queries.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28, still upstream main on 2026-10-29) and jars built at the 10.6.0 RC1 revision4ae946e5a9b(branch_10_6): the minimal test fails with-eaand-da, through gradle and a standalone runner; the 10.5.2 release jars: it passes. The fix was validated in a scratch worktree of645d4fe14a20(the wholelucene/joinmodule, assertions on and off, plus:lucene:join:check -x test). The earlier hunt runs (hunt, verifiers, adjudication, history bisection) used mainea57360912ed, whoseToParentBlockJoinQuery.java,ReqExclBulkScorer.javaandBooleanScorerSupplier.javaare unchanged at645d4fe14a20(re-verified 2026-10-29).
- Finding record
a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:141-142 ('acceptDocs applies (and is checked) only in the parent document space'), :526-545 (BlockJoinBulkScorer.score; :541 childBulkScorer.score(wrappedCollector, acceptDocs, prevParent + 1, lastParent + 1))a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:58-92 (4096-doc windows; :85 windowBits.reset, :86 req.score(collector, windowBits, windowMin, windowMax)), :112-116 (WindowBits.get asserts windowBase <= index < windowEnd)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:198-205 (MUST_NOT with required cost <= 3*maxDoc/1000 returns no bulk scorer, so ReqExclScorer is used), :257-265 (otherwise new ReqExclBulkScorer(positiveScorer, prohibitedScorer))a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BulkScorer.java:70-71 (acceptDocs 'represents the allowed documents to match')
- The finding in 30 seconds
a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:56-57, :98, :141-142, :166-174, :533-541, :586-591, :609-612a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ReqExclBulkScorer.java:59-86, :113-121a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:203-207, :235-236, :256-265, :389-403a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BulkScorer.java:70-71; lucene/core/src/java/org/apache/lucene/search/Weight.java:296-298a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DocAndFloatFeatureBuffer.java:54-57; lucene/core/src/java/org/apache/lucene/search/TermScorer.java:141-147
- Who is affected
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:530-560; a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:205a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:98, :177-180; lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:82-102, :406-450; lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:387-392a Lucene clone c5fa32ce49f:lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java (no MUST_NOT cost threshold before #16715, 48a97520c26)
- Why it looks unintended
a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:56-57, :141-142; a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/package-info.java:37-38a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BulkScorer.java:70-71; lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:58-60a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoin.java:1539-1541a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestBlockJoin.java:826, :852, :1031-1032, :1049, :1074, :1083-1084
- Suggested fix
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/Weight.java:296-298
- History
a Lucene main checkout lucene/CHANGES.txt:687, :695 (Lucene 10.6.0 section from :456)
- TestBlockJoinMustNotExcludesParent.java (one test, public API):
repro/LFV-222/src/org/apache/lucene/search/join/TestBlockJoinMustNotExcludesParent.java(85 lines)
Made in parallel with this doc, from the code at 645d4fe14a20 and an executed probe of the two layouts (6 documents; 6,000 documents with a block straddling doc 4,096).
assets/LFV-222/bug.png(564 KB)
