The join's Weight remembers every segment it was asked for a scorer for and throws on any repeat. That check was meant to catch intra-segment partitions, but it cannot tell them apart from the ordinary second request that BooleanQuery's explain makes, on one thread.
- A block join indexes children and their parent together as one block with
IndexWriter.addDocuments, parent last.ParentsChildrenBlockJoinQueryreturns the children that match a child query and whose parent matches a parent query. - For each segment, a query's
Weightbuilds a scorer that walks the matching documents. Lucene's contract says a scorer for the same segment "may be requested multiple times". IndexSearcher.explainof aBooleanQueryfirst explains each clause, then builds one more scorer for the whole query on that segment, so the explanation shows exactly the search score.
BooleanQuery's explain asks for scorer 2, and the join's seen-segments set throws instead of returning the 0.2615 explanation. Select the still to open it at full size. Generated with AI, checked against the code.- Index one block with
addDocuments: child doc 0 (color:red) and its parent doc 1 (type:parent). The join query, children withcolor:redof parents withtype:parent, matches doc 0. - Combine it with another clause:
BooleanQuery(SHOULD join, SHOULD color:blue). A plainIndexSearcher(no executor, one slice) returns 1 hit, doc 0, score 0.2615. - Call
searcher.explain(bq, 0). You expect a match explanation worth 0.2615, the valueexplain(join, 0)gives on its own. - Instead it throws
IllegalStateException: "ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Context ... was already seen." The join's own explain built scorer 1 for segment 0; theBooleanQuery's explain then asks for scorer 2 to compute the score, and the join refuses any second request for a segment. - Every matching document fails the same way, also inside a
FunctionScoreQuery, and with the join as a FILTER clause when no query cache serves that clause (main's default searcher has none). Search and count still work and returned correct results in every test.
Applications that use the experimental ParentsChildrenBlockJoinQuery (new in 10.4.0) inside a BooleanQuery or FunctionScoreQuery and call explain, for example to debug relevance. Default searcher, codec and settings; a two-document index is enough. Loud and deterministic: one exception per explain call; the search and count calls tested return correct results.
Mechanism, terms and numbers917 words
ParentsChildrenBlockJoinQuery (new in 10.4.0, @lucene.experimental) protects itself against intra-segment concurrency with a Weight-wide Set<LeafReaderContext> seenContexts (ParentsChildrenBlockJoinQuery.java:151 at 645d4fe14a20): the first statement of scorerSupplier throws IllegalStateException("ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Context ... was already seen.") when a segment is requested a second time (:191-197). The Weight#scorer and Weight#scorerSupplier javadoc says a scorer (supplier) for the same LeafReaderContext "may be requested multiple times as part of a single search call" (Weight.java:115-117, :138-140), and Lucene makes such a second request itself, on one thread: BooleanWeight#explain explains every clause (the join's explain pulls a scorer, ParentsChildrenBlockJoinQuery.java:172) and then pulls its own scorer for the same segment to compute the score (BooleanWeight.java:121-126). So IndexSearcher.explain of a BooleanQuery that keeps the join as one of 2+ clauses throws for every matching document (as a non-scoring FILTER clause only when no query cache serves it), on a default IndexSearcher with no executor, with assertions on and off; the same happens for FunctionScoreQuery explain and the test-framework check QueryUtils.checkBulkScorerSkipTo. Search, count and explain of the join on its own work and returned correct results in every test. It has been there since the class was added (#14728, first released in 10.4.0) and is not fixed upstream. A candidate fix that checks the searcher's slices once in createWeight instead of remembering segments was tested: the repro and the whole lucene/join suite pass, including the upstream intra-segment test; the design is upstream's choice.
- The guard.
ParentsChildrenBlockJoinWeightholdsprivate final Set<LeafReaderContext> seenContexts = new HashSet<>();(ParentsChildrenBlockJoinQuery.java:151), andscorerSupplier(context)starts with:
if (!seenContexts.add(context)) { // :192
throw new IllegalStateException( // :193
"ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Context "
+ context + " was already seen.");
}LeafReaderContext does not override equals/hashCode, so the key is the context instance, and the set lives as long as the Weight: after one scorerSupplier call for a segment, every later call for that segment on the same Weight throws, from any thread or partition.
- The join's own explain pulls a scorer.
explain(context, doc)starts withscorer(context)(:172), which callsscorerSupplier(context)(Weight.java:129-134): request 1. BooleanWeight#explainpulls a second one. It callsexplainon every clause (BooleanWeight.java:72-75), decides match or no-match from those (:76-119), and for a match pullsscorer(context)(:126) "to make sure that explanations have the same value as the score" (:121-125).BooleanWeight.scorerSupplierasks every clause for its scorer supplier (:300): request 2, and the guard throws.- Why search and count work.
IndexSearcher.searchLeafasks for one scorer supplier per segment partition (IndexSearcher.java:842), and a searcher without intra-segment partitions has one partition per segment. Explain of the join alone makes only request 1. - What the guard is for. The introducing commit: "Note that this query type will not work with intrasegment concurrency, as it may split a block, leading to overcollection of child documents." With intra-segment concurrency, each partition of a split segment calls
scorerSupplierfor the sameLeafReaderContexton the shared Weight (IndexSearcher.java:760-788,:814,:842). The seen-set detects that, but also every sequential repeat. (The over-collection itself was not executed here; the guard rejecting a split segment loudly was.)
one block: child doc 0 (color:red), parent doc 1 | stock main | with the fix |
|---|---|---|
search(join), search(bq) | 1 hit, doc 0, score 0.26152915 | the same |
explain(join, 0) | match, 0.26152915 | the same |
explain(bq, 0) | IllegalStateException (the guard) | match, 0.26152915 |
explain(bq, 1) (the parent, matches no clause) | no match | no match |
bq = BooleanQuery(SHOULD join, SHOULD color:blue); color:blue matches nothing and only keeps the query at two clauses, because a one-clause BooleanQuery rewrites to its clause (BooleanQuery.java:278-288).
- A document matched only by the other clause; and, on the verifiers' own indexes,
BooleanQuery(MUST join, FILTER type:child)andBooleanQuery(MUST color:blue, FILTER join)(the independent reproduction, whose join selectscolor:bluechildren). On the index above the equivalent isMUST color:red, FILTER join, which throws on main's default searcher. FunctionScoreQueryexplain around the join (FunctionScoreQuery.java:176,:181:inner.explain, theninner.scorerfor the same segment).QueryUtils.checkBulkScorerSkipTo(QueryUtils.java:605,:613:scorerSupplier, thenbulkScoreron one Weight).- A second
Weight.scorer/scorerSuppliercall on a segment, even after the first scorer was fully consumed.
The independent reproduction's class fails 4 of 6 and the intended-behaviour check's probe 5 of 9 on stock main; both pass in full with the fix.
Query cache. A FILTER join throws only when no query cache serves it: only non-scoring clauses go through the cache (BooleanWeight.java:56-61, IndexSearcher.java:979-980), and once the cache holds the join for a segment it answers both requests without asking the join's Weight. On the index above, explain(MUST color:red, FILTER join, 0) throws with setQueryCache(null) but returns a match (0.13076457) with an always-caching LRUQueryCache, on main and on 10.6.0, and also matched under the test framework's random default cache at seed DEADBEEF, so a FILTER-shaped regression test would be flaky. A scoring clause is never cached: the repro's SHOULD join, SHOULD color:blue throws even with the always-caching cache. Main's default searcher has no query cache (IndexSearcher.java:82-84, #14187); every release from 10.4.0 to 10.6.0 defaults to an LRUQueryCache that caches only on segments of 10,000+ documents and, under the default policy, only a filter used several times.
Exposure and likelihood210 words
- Who is hit. Code that combines the join with another clause and explains a hit:
BooleanQuery(SHOULD+SHOULD, the join as MUST with a FILTER clause, the join as FILTER when no query cache serves it),FunctionScoreQueryaround it, and test code that runsQueryUtilschecks on it. The issue that motivated the class (#14565) showsBooleanQuery(MUST ToChildBlockJoinQuery, MUST childQuery), the workaround this class replaces by taking the child query itself; combining a query with other clauses, such as filters, is ordinary Lucene usage. - Default vs opt-in. Default
IndexSearcher(no executor), default codec and settings, assertions on or off; opt-in only in that the query class is experimental and new. The study's code search (default branches) found no use of the class in Solr, Elasticsearch or OpenSearch (a bounded search, not proof that nobody uses it). - Effect. One
IllegalStateExceptionper explain call, for every document the compound query matches; a document that matches no clause gets a normal no-match explanation. The message is misleading: it blames intra-segment concurrency on a searcher that has none. The search and count calls tested (default searcher, no partitions) returned correct results. - Workaround. Explain the join query on its own. No simple public-API workaround that keeps the normal explanation of the compound query was identified.
One JUnit test in lucene/join, public API only: one block of two documents, a plain IndexSearcher, and explain of BooleanQuery(SHOULD join, SHOULD color:blue) on doc 0. Deterministic, single thread. It fails on main with assertions on and off, and on the 10.6.0 release jars.
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 TestParentsChildrenBlockJoinExplain extends LuceneTestCase {
public void testExplainOfBooleanQueryWithJoinClause() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document child = new Document();
child.add(new StringField("color", "red", Field.Store.NO));
Document parent = new Document();
parent.add(new StringField("type", "parent", Field.Store.NO));
w.addDocuments(List.of(child, parent));
try (DirectoryReader reader = DirectoryReader.open(w)) {
Query parents = new TermQuery(new Term("type", "parent"));
Query join =
new ParentsChildrenBlockJoinQuery(
new QueryBitSetProducer(parents), parents, new TermQuery(new Term("color", "red")));
Query bq =
new BooleanQuery.Builder()
.add(join, Occur.SHOULD)
.add(new TermQuery(new Term("color", "blue")), Occur.SHOULD)
.build();
try {
assertTrue(new IndexSearcher(reader).explain(bq, 0).isMatch());
} catch (IllegalStateException e) {
// expected: a match explanation for child doc 0; actual: the per-leaf guard throws
throw new AssertionError("expected a match explanation for doc 0, got " + e, e);
}
}
}
}
}
cp $REPRO/TestParentsChildrenBlockJoinExplain.java $LUCENE/lucene/join/src/test/org/apache/lucene/search/join/
cd $LUCENE && ./gradlew :lucene:join:test --tests org.apache.lucene.search.join.TestParentsChildrenBlockJoinExplain -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
- Expected
explain(bq, 0)returns a match explanation for doc 0 (worth 0.2615, its search score).- Observed
IllegalStateException: ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Context ... was already seen.atParentsChildrenBlockJoinQuery.java:193, fromBooleanWeight.explain:126, with assertions on and off.
More files, environment, full output273 words
The try/catch only turns the IllegalStateException into an AssertionError that states expected vs actual; the exception is chained as the cause. The test framework randomizes, per seed, the default codec, similarity, locale and time zone (seed DEADBEEF ran with codec=Asserting(Lucene104), sim=Asserting(RandomSimilarity(...)), locale=en-NA, timezone=Asia/Bishkek) and installs a default query cache with a random caching policy for every test. None of this changes the outcome: the test asserts only isMatch(), and the join is a scoring (SHOULD) clause, which the query cache never serves. 20 of 20 iterations of --iters 20 (seeds derived from a random root seed) also failed.
Add the Apache license header when you put the file into Lucene. Suggested regression test: this same test, as its own class or as one method in TestParentsChildrenBlockJoinQuery.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run; pure Java logic) |
# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestParentsChildrenBlockJoinExplain > testExplainOfBooleanQueryWithJoinClause FAILED
java.lang.AssertionError: expected a match explanation for doc 0, got java.lang.IllegalStateException: ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Context LeafReaderContext(_0(11.0.0):c2 docBase=0 ord=0) was already seen.
at __randomizedtesting.SeedInfo.seed([DEADBEEF:4D917E5C3C561CE1]:0)
at org.apache.lucene.search.join.TestParentsChildrenBlockJoinExplain.testExplainOfBooleanQueryWithJoinClause(TestParentsChildrenBlockJoinExplain.java:44)
Caused by:
java.lang.IllegalStateException: ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Context LeafReaderContext(_0(11.0.0):c2 docBase=0 ord=0) was already seen.
at org.apache.lucene.search.join.ParentsChildrenBlockJoinQuery$ParentsChildrenBlockJoinWeight.scorerSupplier(ParentsChildrenBlockJoinQuery.java:193)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.BooleanWeight.scorerSupplier(BooleanWeight.java:300)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.Weight.scorer(Weight.java:130)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.BooleanWeight.explain(BooleanWeight.java:126)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.explain(IndexSearcher.java:967)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.explain(IndexSearcher.java:943)
at org.apache.lucene.search.join.TestParentsChildrenBlockJoinExplain.testExplainOfBooleanQueryWithJoinClause(TestParentsChildrenBlockJoinExplain.java:41)
:lucene:join:test (FAILURE): 1 test, 1 failure
# 10.6.0 release jars, -da: the same AssertionError, caused by the same IllegalStateException
# (Context LeafReaderContext(_0(10.6.0):c2 ...)) at ParentsChildrenBlockJoinQuery.java:193 <- BooleanWeight.explain:126
# with the candidate fix on main: 1 test, 0 failures (assertions on and off)Weightjavadoc: a scorer for the same segment "may be requested multiple times"; MIGRATE says Weights that assume one request per segment "need updating".- The guard exists for intra-segment partitions (its message, commit and test), yet it fires on one thread with one slice.
BooleanQueryexplain has made this second request since 2017; the class's 2025 review never discussed explain.
The full argument202 words
- The contract allows repeats, with no exception:
Weight#scorer"A scorer for the sameLeafReaderContextinstance may be requested multiple times as part of a single search call." (Weight.java:115-117), the same forscorerSupplier(:138-140) andbulkScorer(:167-168).MIGRATE.md:1678-1684: "Weightimplementations that rely on the assumption that a scorer, bulk scorer or scorer supplier for a givenLeafReaderContextis requested once per search need updating." - The guard's purpose is partitions only: its message, the introducing commit ("may split a block, leading to overcollection of child documents") and the upstream test
testIntraSegmentConcurrencyNotSupported, which uses aConcurrency.INTRA_SEGMENTsearcher and asserts the exception only when there is more than one slice (TestParentsChildrenBlockJoinQuery.java:406-478). Here there is no executor and one slice. - Older users of the contract:
BooleanWeight#explainhas pulled a second scorer since LUCENE-8008 (d27ddcb409d, 2017-11-29);QueryUtils.checkBulkScorerSkipToasks one Weight twice per segment. - Never discussed: the #14728 conversation and inline reviews do not mention explain, compound queries or repeated requests; the class javadoc (
ParentsChildrenBlockJoinQuery.java:43-51), CHANGES and MIGRATE state no such restriction. The error text andseenContextsoccur nowhere else in Lucene, and the siblingToChildBlockJoinQueryhandles a repeated supplier and a compound explain.
The devil's-advocate check194 words
Strongest case (from the intended-behaviour checker, the cross-model reviewers and the adjudication; all rated it moderate): the guard is deliberate and an upstream test asserts it; the class is @lucene.experimental; the "may be requested multiple times" wording and the MIGRATE note came with intra-segment concurrency (#13542), so a query that opts out of intra-segment search might opt out of that clause; explain "should not be displayed with every hit" (IndexSearcher.java:937-938); and every other upstream test of the class that searches uses a Concurrency.NONE searcher with the query on its own ("Do not use Concurrency.INTRA_SEGMENT as that would break childLimitPerParent counting", TestParentsChildrenBlockJoinQuery.java:185, :246, :369; testIntraSegmentConcurrencyNotSupported uses Concurrency.INTRA_SEGMENT, :464, and testInvalidChildLimit builds no searcher).
Why it fails: the guard rejects a case its own purpose does not cover (one thread, one slice, no partitions) with a message that says otherwise; the repeat clause is unconditional and its users predate the class; a sequential repeat made after the first scorer is fully consumed also throws, so it is not about concurrent state; and a tested candidate fix that keeps the protection for split segments passes the upstream intra-segment test.
Recommended Tested candidate: reject segment-splitting searchers once in createWeight; remove seenContexts
A tested candidate; the design is upstream's choice. Check the searcher's slices once, in createWeight: if a partition covers only part of a segment, throw the same "does not support intraSegment concurrency" error, and drop the per-segment set (+18/-10). The repro then passes, and so does the whole lucene/join suite (105 tests), including the upstream intra-segment test.
// createWeight(): call checkNoSegmentIsSplit(searcher) first, then build the Weight
private static void checkNoSegmentIsSplit(IndexSearcher searcher) {
for (IndexSearcher.LeafSlice slice : searcher.getSlices()) {
for (IndexSearcher.LeafReaderContextPartition p : slice.partitions) {
if (p.minDocId > 0 || p.maxDocId < p.ctx.reader().maxDoc()) {
throw new IllegalStateException(
"ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Segment "
+ p.ctx + " is split into partitions.");
}
}
}
}
// and remove seenContexts and the check at the top of scorerSupplier()All options and trade-offs4 options ยท 946 words
The candidate fix was applied to a scratch worktree of 645d4fe14a20 and run through gradle, and the independent fact check re-ran those steps. It was not tested with an executor running slices in parallel or with custom slice implementations beyond the probe's, and how to detect partitions is upstream's call. Alternatives B and C were not run. Deleting the guard is not a fix.
Tested candidate: reject segment-splitting searchers once in createWeight; remove seenContexts (validated by the checks) (recommended)
In ParentsChildrenBlockJoinQuery.createWeight, walk searcher.getSlices() and throw IllegalStateException("ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Segment ... is split into partitions.") if any LeafReaderContextPartition has minDocId > 0 or maxDocId < maxDoc. Then delete the seenContexts field, its imports and the check at the top of scorerSupplier. The condition matches how IndexSearcher builds partitions: a whole segment is [0, NO_MORE_DOCS), a split segment's parts end at or before maxDoc (IndexSearcher.java:404-416, :1097-1111).
// createWeight(): call checkNoSegmentIsSplit(searcher) first, then build the Weight
private static void checkNoSegmentIsSplit(IndexSearcher searcher) {
for (IndexSearcher.LeafSlice slice : searcher.getSlices()) {
for (IndexSearcher.LeafReaderContextPartition p : slice.partitions) {
if (p.minDocId > 0 || p.maxDocId < p.ctx.reader().maxDoc()) {
throw new IllegalStateException(
"ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Segment "
+ p.ctx + " is split into partitions.");
}
}
}
}
// and remove seenContexts and the check at the top of scorerSupplier()Trade-offs
- Sequential repeats (explain,
FunctionScoreQuery,QueryUtils) work; real partitions are still rejected, now up front and deterministically, instead of when a second partition happens to reach the Weight. - On a searcher that splits segments, every use of the query fails in
createWeight, including explain of the join alone, which works there today (search, and count unless a query cache answers it, already throw there: on stock main with a 4-partition searcher and no cache,count(join)andsearch(join)throw the guard's exception andexplain(join, 0)is a match). Consistent with "does not support intraSegment concurrency", but a behaviour change for such searchers. - Code that partitions a segment without
getSlices()(a subclass calling the protectedsearch(LeafReaderContextPartition[], Weight, Collector)with its own ranges) is no longer caught; today's guard catches it when two ranges of one segment reach the same Weight. - Likewise no longer caught: a custom
slices()that puts the same whole segment into two slices.getSlices()rejects a repeated leaf only within one slice (IndexSearcher.java:549-575) and a whole-segment partition passes the new check; today's guard throws when the second slice asks the same Weight (read, not run). - Removes the Weight's mutable
HashSet, which today is written fromscorerSupplier, a method that concurrent slice tasks call on the same Weight (concurrent access was not tested). - Custom slices that split segments exactly at block boundaries stay rejected, as today.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20with the repro test added, seed DEADBEEF: the repro fails on stock and passes with the fix, with-Ptests.asserts=trueandfalse. TestParentsChildrenBlockJoinQuerywith the fix: 8 tests, 0 failures, includingtestIntraSegmentConcurrencyNotSupported; 80 tests, 0 failures with-Ptests.iters=10.- The whole
:lucene:join:testwith the fix: 17 classes, 105 tests, 0 failures, 3 skipped (@Nightly), assertions on and off. The same suite on stock: 105 tests, 1 failure, the repro. checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainoflucene/joinpass with the fix.- With the fix shadowed into the standalone runner: the independent reproduction's class passes 6 of 6 (stock: 4 fail; its explain checks assert explanation value == search score for every hit) and the intended-behaviour check's probe 9 of 9 (stock: 5 fail;
BooleanQueryexplain on three shapes,FunctionScoreQueryexplain,QueryUtils.checkBulkScorerSkipTo). - A fix-validation probe with a searcher that splits the one segment into four one-document partitions: searching the join still throws "ParentsChildrenBlockJoinQuery does not support intraSegment concurrency." with the fix, as on stock; explain of the join alone there gives a match on stock and the new exception with the fix.
- Not tested: an executor running slices in parallel, and custom slice implementations beyond the probe's.
Compute the same getSlices() condition in createWeight, pass it to the Weight, and skip the seenContexts check when no segment is split.
Trade-offs
Smallest behaviour change: searchers without intra-segment partitions (the default, and inter-segment concurrency) are fixed, and segment-splitting searchers behave exactly as today. But it keeps the identity-based check, so explain of a compound query still fails on segment-splitting searchers, and it keeps the mutable set. Not run.
Instead of rejecting every split, reject only partitions that cut a block (minDocId > 0 and doc minDocId - 1 is not a parent), the constraint BlockGroupingCollectorManager documents for block grouping (BlockGroupingCollectorManager.java:36-43).
Trade-offs
More permissive and precise, but it needs the parent bit set per partition, and it changes what the upstream test asserts: testIntraSegmentConcurrencyNotSupported expects the exception whenever there is more than one slice, while IndexSearcher.slices puts partition boundaries at multiples of maxDoc / numSlices (IndexSearcher.java:398-416), which can fall on a block boundary. The lower-boundary condition is enough only while the partitions of a segment are contiguous and cover it, as IndexSearcher builds them; arbitrary custom partitions also need the matching upper-boundary check (maxDocId == maxDoc, or doc maxDocId - 1 is a parent). Not run.
Removing seenContexts and its check makes every repeated-request test pass. (The study's root-cause check only: the repro tests pass, the upstream test fails 1 of 8.)
Trade-offs
It fails the upstream testIntraSegmentConcurrencyNotSupported (8 run, 1 failed), and by the introducing commit's rationale intra-segment search would then over-collect children silently instead of failing.
Downstream products are affected only if their own code builds this experimental query and explains compound queries with it; the study's bounded code search (default branches, not exhaustive) found no use of the class in Solr, Elasticsearch or OpenSearch.
A hand-written probe: while triaging explain-versus-score differences that a bounded-exhaustive harness reported for this query, the finder explained it inside a BooleanQuery and hit the exception. The harness runs the query only on its own and never saw it; a public-API JUnit confirmed it.
The method in full109 words
Method: non-formal (a hand-written probe, then a directed JUnit), 2026-10-26, main ea57360912ed. The hunt unit's bounded-exhaustive translation-validation harness compares explain with the search score; it builds ParentsChildrenBlockJoinQuery only standalone and reported no exception for it. While triaging that harness's other findings for the class, the finder explained the query inside a BooleanQuery, hit the exception, checked the Weight javadoc, and wrote a public-API JUnit on the stock main jar (2 of 2 fail, -ea and -da). A second hunt unit later hit the same exception on its own and deferred to this finding. A minimisation step replaced the finder's test with the one-method test shown here.
No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream projects or the web (searched 2026-10-26, refreshed 2026-10-30). The closest items are #14728, which added the guard, and #15041, a different explain problem of the same class.
Searches3 searches
Context, not reports: #14728 (added the class and the guard; its review never discussed explain, compound queries or repeated requests); #14565 (the feature request); #13542 (the "requested multiple times" contract and the MIGRATE note); #15041 / #15042 (a test-only explain mismatch after merges, no exception); #14975 (test flakiness); #13745 (sharing per-segment work across partitions); #16229 (changed BooleanWeight.explain output, kept the second scorer pull). Limits: keyword search only; JIRA predates the class; private channels are not searchable.
| Where | When | How | Found |
|---|---|---|---|
| GitHub, mailing lists, JIRA archive, web (search script) | 2026-10-26 | 51 queries: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA archive, 6 web | 661 hits, none about this bug; the only high-ranked hit is #13542 (background) |
| GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 28 manual searches (class name, error text, seenContexts, explain wordings); the changed files of all 413 open apache/lucene PRs; full reads of #14728, #14565, #14975, #15041, #13542, #13745 | no report; the error text appears only in source code |
| GitHub (apache/lucene, all of GitHub) | 2026-10-30 | issue and PR searches for the class name, the error text, "was already seen" LeafReaderContext, seenContexts and explain wordings; open PRs mentioning the class | only #14728 and #15041; no open PR |
Introduced with the class by #14728 (1584c05b27a, 2025-06-13; backported for 10.4.0) and unchanged since. Every release from 10.4.0 to 10.6.0 and main are affected; not fixed upstream.
Versions tested8 rows
The guard came from the PR-branch commit b86181a44fd "Do not support IntraSegment Concurrency in ParentsChildrenBlockJoinQuery" (2025-06-09), squash-merged into main as 1584c05b27a (2025-06-13, Jinny Wang, merged by msfroh) and backported to branch_10x as 61b32ff6d37 (2025-09-04). git log -S seenContexts finds only 1584c05b27a; the file does not exist at its parent. The only later commit to the file, bc601411a31 (#16519, 2026-08-19), changes one line: the inner scorer supplier's cost() now throws IOException (the setTopLevelScoringClause() throws IOException change was the separate branch_10x commit 8f2f9f3f71e, not on main). It collides with older code: BooleanWeight#explain's second scorer pull (d27ddcb409d, LUCENE-8008, 2017) and the "may be requested multiple times" javadoc (fafd6af004e, #13542, 2024, in 10.0.0). git tag --contains 61b32ff6d37 lists 10.4.0 to 10.6.0. Upstream main 2f424fd428da, branch_11x d0c0decc6606 and branch_10_6 91eaab4fb298 (2026-10-30) still carry the guard.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.3, 10.3.2 | class absent | the repro does not compile (no ParentsChildrenBlockJoinQuery) |
| 10.4.0 | first release | the finder's JUnit fails 2 of 2 (-ea, -da) |
| 10.5.0, 10.5.1 | affected | the finder's JUnit fails 2 of 2 (-ea) |
| 10.5.2 | affected | the finder's JUnit fails 2 of 2 (-ea, -da) |
10.6.0 (4ae946e5a9b9) | affected | the minimal test fails (-da), the same exception at :193 |
main 645d4fe14a20 | affected | the minimal test fails, gradle and standalone, assertions on and off |
upstream main 2f424fd428da, branch_11x d0c0decc6606, branch_10_6 91eaab4fb298 | source read | seenContexts guard unchanged (2026-10-30) |
main 645d4fe14a20 + candidate fix | fix | the minimal test passes; the whole join suite: 105 tests, 0 failures |
The finder, an independent reproducer, cross-model reviews, an intended-behaviour devil's advocate and an adjudication all reproduced it and called it real; a second hunt unit hit the same exception on its own. This doc's runs reproduced it on main and 10.6.0 and tested the candidate fix through gradle; an independent fact check re-ran both.
Checks and verdicts391 words
Every executed check reproduced the same IllegalStateException; every control passed (search and count of the compound query, explain of the join alone, explain of a document that matches no clause, distinct join Weights, the sibling ToChildBlockJoinQuery, repeated search with an always-caching query cache).
| Check | Verdict | What it found |
|---|---|---|
| finder (probe, then a JUnit) | candidate | Public-API JUnit fails 2 of 2 on main (-ea, -da), 10.4.0 and 10.5.2. |
| Independent reproducer | real | Own JUnit in a foreign package, query cache off: 4 of 6 fail on main, 10.5.2 and 10.4.0; 40 of 40 claim iterations fail over 10 random seeds; controls pass. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real, high | Own test fails 2 of 3 on main (-ea, -da); controls pass; rejected guard deletion as the fix. |
| Cross-model review, works-as-designed lens (OpenAI GPT-5.6, executed) | real, high | Own test fails 2 of 4, including a repeat after the first scorer was fully consumed; the intended-behaviour argument rated moderate and rejected. |
| Intended-behaviour devil's advocate | real | Own probe fails 5 of 9 on main, 10.4.0 and 10.5.2 (BooleanQuery shapes, FunctionScoreQuery, QueryUtils); 4 controls pass. |
| adjudication | confirmed, high | Re-ran the independent reproduction (4 of 6 fail, -ea and -da) and the finder's JUnit (2 of 2 fail). |
| second hunt unit (independent rediscovery) | same exception, deferred | Its own lead test fails with the same exception on main and 10.5.2; not a separate bug. |
| minimisation (2026-10-29) | verified | The one-method test fails on 645d4fe14a20 with -ea and -da; 20 of 20 iterations fail. |
| this doc's runs (2026-10-30) | reproduced; candidate fix tested | The minimal test fails on main 645d4fe14a20 (gradle and standalone, -ea and -da) and on 10.6.0; with the fix it passes and the whole join suite runs clean (105 tests, 0 failures). |
| independent fact check (2026-10-31) | bug holds; 7 precision fixes | Re-ran the gradle repro (asserts on and off) and the fix (repro, TestParentsChildrenBlockJoinQuery 8 of 8, lucene/join 105 tests) in its own worktree; probed the FILTER shape under three query-cache settings on main and 10.6.0. Its precision findings were fixed. |
| cross-model fact check (OpenAI GPT-5.6) | real; wording fixes | Read-only review of the brief against the code and the saved runs: core bug real and well evidenced; 3 factual errors, 5 overclaims and 5 missing caveats, all handled. |
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 471 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,404 words
- Registry title
- ParentsChildrenBlockJoinQuery throws IllegalStateException ('does not support intraSegment concurrency') when a scorer is requested twice for the same leaf, so IndexSearcher.explain of a BooleanQuery that contains it fails, even on a plain single-threaded searcher
- Area
- join / block join (ParentsChildrenBlockJoinQuery: ParentsChildrenBlockJoinWeight#scorerSupplier intra-segment guard)
- Priority
- Low, rank 702: ParentsChildrenBlockJoinQuery refuses a second scorer request per leaf, so explain of any compound query containing it throws, loud and diagnostic-only.
- Impact class
- crash
- Impact note
- Applications that use the experimental ParentsChildrenBlockJoinQuery (new in 10.4.0) and call IndexSearcher.explain on a BooleanQuery that keeps it as one of 2+ clauses (SHOULD+SHOULD, the join as a FILTER under a MUST clause, and the join as MUST with a FILTER clause verified) or on a FunctionScoreQuery around it get an IllegalStateException whose 'does not support intraSegment concurrency' message is false on this path, with a default IndexSearcher (no executor), the default codec, -ea and -da; any code that asks one Weight twice for a scorer or scorer supplier on the same leaf fails the same way (for example the test-framework check QueryUtils.checkBulkScorerSkipTo); search, count and explain of the join query on its own work, and no result is ever wrong.
- Summary
- You search with a BooleanQuery that combines a ParentsChildrenBlockJoinQuery with another clause and call IndexSearcher.explain on a hit, and you expect an explanation, because the Weight#scorer and Weight#scorerSupplier javadoc say a scorer (supplier) for the same LeafReaderContext may be requested multiple times as part of a single search call. Instead explain throws IllegalStateException 'ParentsChildrenBlockJoinQuery does not support intraSegment concurrency. Context ... was already seen.': the join query's Weight records every leaf it has built a scorer supplier for and refuses a second request, and BooleanWeight#explain makes exactly that second request (the join clause's own explain pulls a scorer, then BooleanWeight pulls its scorer for the same leaf to compute the score). The search itself returns the right hits; explain fails for every document that the BooleanQuery matches, including documents matched only by the other clause.
- Introduced
- 1584c05b27ac31fbccb0ab328bf9f8eb6a7de414 (main, squash merge of PR #14728 '[BlockJoin] Add ParentsChildrenBlockJoinQuery', issue #14565, by Jinny-Wang, merged by msfroh; the guard came from the PR-branch commit b86181a44fde 'Do not support IntraSegment Concurrency in ParentsChildrenBlockJoinQuery', 2025-06-09) and its branch_10x backport 61b32ff6d3772906b12b9e7f855b8607e9261371; the class and the guard landed together [read: git blame of ParentsChildrenBlockJoinQuery.java:151 and :192-196, and git log -S 'does not support intraSegment concurrency' and -S seenContexts over the full history, return only 1584c05b27a; the file does not exist at 1584c05b27a^ or 61b32ff6d37^, so there is no commit-level run; exec: the release boundary, 10.3.2 lacks the class and 10.4.0 fails]; it collides with older code: BooleanWeight#explain has pulled a second scorer since d27ddcb409dd (LUCENE-8008, 2017-11-29) and the Weight javadoc 'may be requested multiple times' dates from fafd6af004e0 (#13542, 2024-09-10, in 10.0.0) (2025-06-13 (main squash merge; guard first written in the PR branch 2025-06-09); branch_10x backport 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 repros do not compile there [exec])
- Affected versions
- 10.4.0, 10.5.0, 10.5.1, 10.5.2 (the last release before 10.6.0) and main ea57360912ed [exec: the finder's JUnit fails 2 of 2 on each with -ea, and with -da on 10.4.0, 10.5.2 and main (history step, 11 runs); the independent reproduction's JUnit fails 4 of 6 on main -ea and -da, 10.5.2 -ea and -da and 10.4.0 -ea; the intended-behaviour check's probe fails 5 of 9 on main -ea and -da, 10.4.0 and 10.5.2]; the guard is unchanged since the class was added (the only other commit to the file on main, bc601411a31 / #16519, changes only the cost() and setTopLevelScoringClause() signatures) [read]; not fixed upstream: upstream main, branch_10x and branch_10_6 (so 10.6.0, by reading) still carry the guard and no open PR touches the file [read-only check 2026-10-26, not executed on branch_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
- non-formal
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: 84 query lines, among them 51 scripted prior-report search queries (16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 6 web; 661 hits, the only 'high' hit #13542 is background) and 28 manual gh searches over apache/lucene, all of GitHub and the Elasticsearch, OpenSearch and Solr repositories (the error text appears in no issue anywhere, only in source code); the changed files of all 413 open apache/lucene PRs (none touches the file or its test); the file's history on main, branch_10x and branch_10_6; full reads of #14728, #14565, #14975, #15041, #13542, #13745, #16858, #16847, #10526 and LUCENE-9762; the finder's 9-query run and the intended-behaviour check's 43-query run also found nothing); not fixed: the guard is still on upstream main, branch_10x and branch_10_6; related, not reports of this bug: #14728 (added the class and the guard; its review did not discuss explain, compound queries or repeated scorer requests), #13542 (the 'requested multiple times' contract), #15041/#15042 (a different, test-only explain problem of the same class, no exception), #16229 (changed BooleanWeight.explain output and kept the second scorer pull); gaps: GitHub search is lexical and best effort, JIRA predates the class, code search does not index every repository, and private reports to security@lucene.apache.org are not visible
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-30: the minimal test through gradle in a scratch worktree with assertions on and off, stock and with the candidate fix, plus the wholelucene/jointest suite with the fix and without it; the standalone runner with-eaand-da; the 10.6.0 release jars (-da). Earlier runs in this hunt: 10.4.0, 10.5.0, 10.5.1 and 10.5.2 (the finder's JUnit), and mainea57360912edanda9779ef3c193, whereParentsChildrenBlockJoinQuery.javais identical. Upstream main2f424fd428da,branch_11xd0c0decc6606andbranch_10_691eaab4fb298were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run. An independent fact check on 2026-10-31 re-ran the gradle repro and the fix, and probed the FILTER shape under three query-cache settings on main and the 10.6.0 jars, and count on a segment-splitting searcher on main.
- Finding record
lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51 (class javadoc, @lucene.experimental at :50, no restriction documented), :151 (Weight-wide HashSet seenContexts), :171-188 (explain pulls scorer(context) at :172), :191-197 (guard, the first statement of scorerSupplier; throw at :193)lucene/core/src/java/org/apache/lucene/search/Weight.java:115-117 ('A scorer for the same LeafReaderContext instance may be requested multiple times as part of a single search call') and :138-140 (the same for scorer suppliers)lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:64-117 (explain calls every clause's explain and decides match or no-match) and :121-126 (comment and the second scorer(context) pull); lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:175-181 (inner.explain then inner.scorer for the same leaf); lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:605-613 (scorerSupplier then bulkScorer on one Weight and leaf)lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:279-296 (a 1-clause BooleanQuery rewrites to its clause, so the explain trigger needs 2+ clauses)lucene/CHANGES.txt:1320 (GITHUB#14565 under Lucene 10.4.0 New Features)
- The finding in 30 seconds
lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51 (class javadoc, @lucene.experimental), :151 (seenContexts), :171-188 (explain; scorer(context) at :172), :191-197 (guard; throw at :193)lucene/core/src/java/org/apache/lucene/search/Weight.java:115-117, :129-134, :138-140lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:72-75, :76-119, :121-126, :297-300lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:760-788, :814, :842lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:278-288 (one-clause rewrite)lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:175-181; lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:599-613lucene/core/src/java/org/apache/lucene/index/LeafReaderContext.java (toString at :72; no equals/hashCode override)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84 (no default query cache), :979-980 (only non-scoring weights are cached); BooleanWeight.java:56-61 (non-scoring clauses get COMPLETE_NO_SCORES)a Lucene clone releases/lucene/10.6.0: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:95-100 (default LRUQueryCache; the same in 10.4.0 to 10.5.2), LRUQueryCache.java:175-176 (MinSegmentSizePredicate(10000)), UsageTrackingQueryCachingPolicy.java:122-137 (cache after 5 uses, 4 for compound queries)
- Who is affected
GitHub apache/lucene#14565 issue body (read-only gh): BooleanQuery(MUST ToChildBlockJoinQuery, MUST childQuery) as the current workaround
- Minimal repro
lucene/test-framework/src/java/org/apache/lucene/tests/util/SetupAndRestoreQueryCache.java:31-49 (fresh default query cache, random caching policy)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Weight.java:115-117, :138-140, :167-168lucene/MIGRATE.md:1678-1684lucene/join/src/test/org/apache/lucene/search/join/TestParentsChildrenBlockJoinQuery.java:185-186, :246-247, :369-370, :406-478 (Concurrency.INTRA_SEGMENT at :464), :322 (testInvalidChildLimit, no searcher)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:937-938lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51lucene/CHANGES.txt:1353 (GITHUB#14565, Lucene 10.4.0 section starting at :1298)grep -rn 'seenContexts\|was already seen' lucene --include='*.java' (only ParentsChildrenBlockJoinQuery.java)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:398-416, :1097-1111lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollectorManager.java:36-43lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:549-575 (repeated leaves rejected only within one slice)
- History
a Lucene clone: git tag --contains 61b32ff6d37; git show releases/lucene/10.6.0:lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java (guard at :151, :192-196)
- Diagrams
lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:151, :172, :191-197; lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:72-75, :126
- TestParentsChildrenBlockJoinExplain.java (lucene/join test, one method):
repro/LFV-167/src/org/apache/lucene/search/join/TestParentsChildrenBlockJoinExplain.java(49 lines)
assets/LFV-167/bug.png(471 KB)
