All findings · Lucene finding · LFV-167 · parentschildren-scorersupplier-rejects-repeated-context

ParentsChildrenBlockJoinQuery refuses a second scorer for the same segment, so explain() of a BooleanQuery that contains it throws

ParentsChildrenBlockJoinQuery throws when asked twice for a scorer on one segment, and explain() of a BooleanQuery asks twice. So explaining a compound query containing the join (as a scoring clause, or as an uncached filter) fails with "does not support intraSegment concurrency" on a plain single-threaded searcher, since 10.4.0.

Summary

Priority
Low, rank 702ParentsChildrenBlockJoinQuery refuses a second scorer request per leaf, so explain of any compound query containing it throws, loud and diagnostic-only.
Impact
crash
Since
10.4.0 (#14728, 2025-06-13), with the class itselfintroduced 2025-06-13 · about 1 year
Affected
every release from 10.4.0 to 10.6.0, and main
Status
documented
Found by
a hand-written probe while triaging an explain-versus-score harness, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: block joins, scorers and explain

  1. A block join indexes children and their parent together as one block with IndexWriter.addDocuments, parent last. ParentsChildrenBlockJoinQuery returns the children that match a child query and whose parent matches a parent query.
  2. For each segment, a query's Weight builds a scorer that walks the matching documents. Lucene's contract says a scorer for the same segment "may be requested multiple times".
  3. IndexSearcher.explain of a BooleanQuery first explains each clause, then builds one more scorer for the whole query on that segment, so the explanation shows exactly the search score.
LFV-167 summary: one block with child doc 0 (color:red) and parent doc 1 (type:parent). BooleanQuery(SHOULD join, SHOULD color:blue) finds doc 0 with score 0.2615. explain(bq, 0) first explains the join clause, which builds scorer 1 for segment 0, then asks every clause for scorer 2 to compute the score. The join's Weight remembers segment 0 and throws IllegalStateException 'does not support intraSegment concurrency ... was already seen' at ParentsChildrenBlockJoinQuery.java:193, although the searcher has no executor and one slice. Expected: a match explanation worth 0.2615. Bottom box: a tested candidate fix, rejecting segment-splitting searchers once in createWeight and dropping seenContexts, passes the repro and the whole join suite including testIntraSegmentConcurrencyNotSupported; deleting the guard fails that test and is not the fix.
The minimal repro step by step: one block (child doc 0, parent doc 1), the join's explain builds scorer 1 for segment 0, the 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.

The finding, step by step

  1. Index one block with addDocuments: child doc 0 (color:red) and its parent doc 1 (type:parent). The join query, children with color:red of parents with type:parent, matches doc 0.
  2. Combine it with another clause: BooleanQuery(SHOULD join, SHOULD color:blue). A plain IndexSearcher (no executor, one slice) returns 1 hit, doc 0, score 0.2615.
  3. Call searcher.explain(bq, 0). You expect a match explanation worth 0.2615, the value explain(join, 0) gives on its own.
  4. 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; the BooleanQuery's explain then asks for scorer 2 to compute the score, and the join refuses any second request for a segment.
  5. 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.

Who is affected

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 mechanism (main 645d4fe14a20)

  1. The guard. ParentsChildrenBlockJoinWeight holds private final Set<LeafReaderContext> seenContexts = new HashSet<>(); (ParentsChildrenBlockJoinQuery.java:151), and scorerSupplier(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.

  1. The join's own explain pulls a scorer. explain(context, doc) starts with scorer(context) (:172), which calls scorerSupplier(context) (Weight.java:129-134): request 1.
  2. BooleanWeight#explain pulls a second one. It calls explain on every clause (BooleanWeight.java:72-75), decides match or no-match from those (:76-119), and for a match pulls scorer(context) (:126) "to make sure that explanations have the same value as the score" (:121-125). BooleanWeight.scorerSupplier asks every clause for its scorer supplier (:300): request 2, and the guard throws.
  3. Why search and count work. IndexSearcher.searchLeaf asks 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.
  4. 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 scorerSupplier for the same LeafReaderContext on 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.)

The numbers (executed, seed DEADBEEF)

one block: child doc 0 (color:red), parent doc 1stock mainwith the fix
search(join), search(bq)1 hit, doc 0, score 0.26152915the same
explain(join, 0)match, 0.26152915the same
explain(bq, 0)IllegalStateException (the guard)match, 0.26152915
explain(bq, 1) (the parent, matches no clause)no matchno 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).

Other shapes that fail the same way (study verifiers, re-run here)

  • A document matched only by the other clause; and, on the verifiers' own indexes, BooleanQuery(MUST join, FILTER type:child) and BooleanQuery(MUST color:blue, FILTER join) (the independent reproduction, whose join selects color:blue children). On the index above the equivalent is MUST color:red, FILTER join, which throws on main's default searcher.
  • FunctionScoreQuery explain around the join (FunctionScoreQuery.java:176, :181: inner.explain, then inner.scorer for the same segment).
  • QueryUtils.checkBulkScorerSkipTo (QueryUtils.java:605, :613: scorerSupplier, then bulkScorer on one Weight).
  • A second Weight.scorer / scorerSupplier call 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), FunctionScoreQuery around it, and test code that runs QueryUtils checks on it. The issue that motivated the class (#14565) shows BooleanQuery(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 IllegalStateException per 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.

Minimal repro

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.

TestParentsChildrenBlockJoinExplain.java (lucene/join test, one method)49 lines
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);
        }
      }
    }
  }
}
Command
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. at ParentsChildrenBlockJoinQuery.java:193, from BooleanWeight.explain:126, with assertions on and off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run; pure Java logic)

Observed output

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

Why it looks unintended

  • Weight javadoc: 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.
  • BooleanQuery explain 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 same LeafReaderContext instance may be requested multiple times as part of a single search call." (Weight.java:115-117), the same for scorerSupplier (:138-140) and bulkScorer (:167-168). MIGRATE.md:1678-1684: "Weight implementations that rely on the assumption that a scorer, bulk scorer or scorer supplier for a given LeafReaderContext is 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 a Concurrency.INTRA_SEGMENT searcher 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#explain has pulled a second scorer since LUCENE-8008 (d27ddcb409d, 2017-11-29); QueryUtils.checkBulkScorerSkipTo asks 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 and seenContexts occur nowhere else in Lucene, and the sibling ToChildBlockJoinQuery handles 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.

Suggested fix

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) and search(join) throw the guard's exception and explain(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 protected search(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 from scorerSupplier, 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 645d4fe14a20 with the repro test added, seed DEADBEEF: the repro fails on stock and passes with the fix, with -Ptests.asserts=true and false.
  • TestParentsChildrenBlockJoinQuery with the fix: 8 tests, 0 failures, including testIntraSegmentConcurrencyNotSupported; 80 tests, 0 failures with -Ptests.iters=10.
  • The whole :lucene:join:test with 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, ecjLintMain and forbiddenApisMain of lucene/join pass 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; BooleanQuery explain on three shapes, FunctionScoreQuery explain, 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.

Keep the per-segment guard, but only when the searcher splits segments

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.

Allow partitions that start on a block boundary

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.

Not recommended: delete the guard

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 note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, mailing lists, JIRA archive, web (search script)2026-10-2651 queries: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA archive, 6 web661 hits, none about this bug; the only high-ranked hit is #13542 (background)
GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr)2026-10-2628 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, #13745no report; the error text appears only in source code
GitHub (apache/lucene, all of GitHub)2026-10-30issue and PR searches for the class name, the error text, "was already seen" LeafReaderContext, seenContexts and explain wordings; open PRs mentioning the classonly #14728 and #15041; no open PR

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
9.12.3, 10.3.2 class absentthe repro does not compile (no ParentsChildrenBlockJoinQuery)
10.4.0 first releasethe finder's JUnit fails 2 of 2 (-ea, -da)
10.5.0, 10.5.1 affectedthe finder's JUnit fails 2 of 2 (-ea)
10.5.2 affectedthe finder's JUnit fails 2 of 2 (-ea, -da)
10.6.0 (4ae946e5a9b9) affectedthe minimal test fails (-da), the same exception at :193
main 645d4fe14a20 affectedthe minimal test fails, gradle and standalone, assertions on and off
upstream main 2f424fd428da, branch_11x d0c0decc6606, branch_10_6 91eaab4fb298 source readseenContexts guard unchanged (2026-10-30)
main 645d4fe14a20 + candidate fix fixthe minimal test passes; the whole join suite: 105 tests, 0 failures

Independent checks

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

Each independent check and its verdict
CheckVerdictWhat it found
finder (probe, then a JUnit) candidatePublic-API JUnit fails 2 of 2 on main (-ea, -da), 10.4.0 and 10.5.2.
Independent reproducer realOwn 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, highOwn 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, highOwn 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 realOwn probe fails 5 of 9 on main, 10.4.0 and 10.5.2 (BooleanQuery shapes, FunctionScoreQuery, QueryUtils); 4 controls pass.
adjudication confirmed, highRe-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, deferredIts own lead test fails with the same exception on main and 10.5.2; not a separate bug.
minimisation (2026-10-29) verifiedThe 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 testedThe 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 fixesRe-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 fixesRead-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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,404 words

Finding record

LFV-167, snapshot of 2026-11-06

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 whole lucene/join test suite with the fix and without it; the standalone runner with -ea and -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 main ea57360912ed and a9779ef3c193, where ParentsChildrenBlockJoinQuery.java is identical. Upstream main 2f424fd428da, branch_11x d0c0decc6606 and branch_10_6 91eaab4fb298 were 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.

Lucene and upstream sources

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-140
  • lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:72-75, :76-119, :121-126, :297-300
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:760-788, :814, :842
  • lucene/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-613
  • lucene/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-168
  • lucene/MIGRATE.md:1678-1684
  • lucene/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-938
  • lucene/join/src/java/org/apache/lucene/search/join/ParentsChildrenBlockJoinQuery.java:43-51
  • lucene/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-1111
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollectorManager.java:36-43
  • lucene/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

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

Repro files

About the diagrams

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.