All findings · Lucene finding · LFV-1222 · disjunction-block-propagator-sort-wraps-ioexception

A top-k DisjunctionMaxQuery search turns a clause's getMaxScore IOException into a RuntimeException

When a clause's Scorer.getMaxScore throws IOException, a top-k search of a DisjunctionMaxQuery or LogOddsFusionQuery can throw a plain RuntimeException wrapping it, not the declared IOException (a top-level tie-breaker-0 DisjunctionMaxQuery is spared). Only custom extension code triggers it; the search still fails; every sampled release since 8.3.0 is affected.

Summary

Priority
Low, rank 839Latent exception-type inconsistency: a top-k DisjunctionMaxQuery or LogOddsFusionQuery search (a top-level tie-breaker-0 DisjunctionMaxQuery is spared) throws a RuntimeException wrapping the IOException instead of the declared IOException when a clause's getMaxScore fails, since 8.3.0 (10.5.0 for LogOddsFusionQuery). No in-tree scorer does I/O there, so only custom extension code (such as a custom scorer) triggers it, and the search still fails with the IOException as the cause.
Impact
minor
Since
8.3.0 (2019, LUCENE-8922)introduced 2019-07-24 · about 7 years
Affected
8.3.0 to 10.6.0 and main (sampled releases run)
Status
documented
Found by
a proof-obligation audit of DisjunctionScoreBlockBoundaryPropagator's exception contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

To speed up a top-k search, DisjunctionMaxQuery sorts its clauses by best possible score so it can ignore weak ones when it skips ahead. The sort key calls getMaxScore, which may throw IOException; a Java lambda cannot throw it, so the comparator wraps it in RuntimeException. The same call four lines after the sort throws plain IOException.

Diagram of a top-k DisjunctionMaxQuery search over a MatchAllDocsQuery clause and a custom clause whose getMaxScore throws IOException. The propagator's constructor first sorts the clauses with a comparator lambda that calls getMaxScore and wraps the IOException in RuntimeException (line 40), then calls getMaxScore again (line 58), where an IOException passes through. search(q, 10) therefore throws java.lang.RuntimeException: java.io.IOException: getMaxScore failed; expected: the IOException. The fix panel shows the smallest fix (throw UncheckedIOException in the comparator and rethrow its cause around the sort); the recommended fix reads the max scores once before the sort. Who can hit it: custom extension code only; a top-level DisjunctionMaxQuery with tie-breaker 0 is spared, one next to another scoring clause in a BooleanQuery is not.
One top-k search, two identical getMaxScore calls: the failure inside the clause sort comes out as RuntimeException, the same failure right after the sort as the declared IOException. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You write a custom query whose scorer's getMaxScore can throw IOException (it reads data there), index 1 document, and run searcher.search(new DisjunctionMaxQuery(List.of(new MatchAllDocsQuery(), custom), 0.1f), 10).
  2. For a top-10 search Lucene first sorts the 2 clauses by their best possible score, getMaxScore(NO_MORE_DOCS), so it can later ignore weak clauses when it skips ahead. Your clause throws during that sort.
  3. You expect the IOException: getMaxScore and IndexSearcher.search both declare it, and the same failure one call later, right after the sort, does arrive as IOException.
  4. Instead search throws java.lang.RuntimeException: java.io.IOException: getMaxScore failed. The sort's comparator is a lambda, which cannot throw a checked exception, so it wraps it; a catch (IOException e) misses it.

Who is affected

Only applications or plugins whose own extension code can throw IOException from a clause's getMaxScore, used under a DisjunctionMaxQuery or a LogOddsFusionQuery in a top-k search (a top-level DisjunctionMaxQuery with tie-breaker 0 is spared), and that catch IOException by type. No Lucene scorer throws there. The search still fails, with the IOException as the cause. Priority Low.

Mechanism, terms and numbers557 words

For a top-k search (ScoreMode.TOP_SCORES), DisjunctionMaxScorer and LogOddsFusionScorer build a DisjunctionScoreBlockBoundaryPropagator, whose constructor sorts the clause scorers by getMaxScore(NO_MORE_DOCS). The sort key is a lambda, so it catches the IOException that getMaxScore may throw and rethrows it as new RuntimeException(e) (DisjunctionScoreBlockBoundaryPropagator.java:40 on main). The constructor itself declares IOException and, four lines after the sort, makes the identical call without wrapping, so the type the caller sees depends only on whether the failure happens during the sort or right after it. IndexSearcher.search then throws an unchecked RuntimeException with the IOException as its cause, and code that catches IOException misses it. No Lucene scorer does I/O in getMaxScore(NO_MORE_DOCS), so only custom extension code reaches it (a custom scorer, value source or postings format); a top-level DisjunctionMaxQuery with tie-breaker 0 is spared, one nested among several scoring clauses of a BooleanQuery is not; no result is wrong. The wrap came with LUCENE-8922 (8.3.0); 8.2.0 surfaced the IOException. The fix reads the max scores before the sort.

Terms

  • getMaxScore(upTo): a Scorer method that returns an upper bound of the scores it can produce up to doc upTo; it declares IOException (Scorer.java:79). Top-k search uses these bounds to skip documents and clauses that cannot make the top k.
  • DisjunctionScoreBlockBoundaryPropagator: a helper that DisjunctionMaxScorer (DisjunctionMaxScorer.java:52-53) and LogOddsFusionScorer (LogOddsFusionScorer.java:103-104) build only in TOP_SCORES mode. It sorts the clauses by getMaxScore(NO_MORE_DOCS) so it can stop looking at clauses whose best score is below the current threshold.

The bug

// DisjunctionScoreBlockBoundaryPropagator.java:34-43
private static final Comparator<Scorer> MAX_SCORE_COMPARATOR =
    Comparator.comparing(
            (Scorer s) -> {
              try {
                return s.getMaxScore(DocIdSetIterator.NO_MORE_DOCS);
              } catch (IOException e) {
                throw new RuntimeException(e);          // :40
              }
            })
        .thenComparingLong(s -> s.iterator().cost());

// :49-60, the constructor declares IOException
for (Scorer scorer : this.scorers) scorer.advanceShallow(0);          // :52  IOException passes
Arrays.sort(this.scorers, MAX_SCORE_COMPARATOR);                       // :54  becomes RuntimeException
maxScores[i] = this.scorers[i].getMaxScore(DocIdSetIterator.NO_MORE_DOCS); // :58  passes (loop)
  1. IndexSearcher.search(q, 10) asks DisjunctionMaxQuery's scorer supplier for a scorer (DisjunctionMaxQuery.java:158); in TOP_SCORES mode DisjunctionMaxScorer builds the propagator (DisjunctionMaxScorer.java:53).
  2. The propagator's Arrays.sort (:54) calls the comparator, which calls the custom clause's getMaxScore; it throws IOException("getMaxScore failed"), and the lambda rethrows it as RuntimeException (:40).
  3. The RuntimeException travels up through IndexSearcher.search unchanged; the IOException is its cause.

Controls from the hunt's probe and the fact check's probe (main, assertions off): a scorer that fails only on its 2nd getMaxScore call gets java.io.IOException at :58; one that fails in advanceShallow gets IOException at :52; a COMPLETE (non-pruning) search builds no propagator and returns 1 hit; LogOddsFusionQuery gets the same RuntimeException at :40.

Which DisjunctionMaxQuery searches build it. ScorerSupplier.get builds DisjunctionMaxScorer for any tie-breaker (DisjunctionMaxQuery.java:153-159). Only bulkScorer() avoids it: with tie-breaker 0 in TOP_SCORES mode it returns a DisjunctionMaxBulkScorer (:162-170), so a top-level tie-breaker-0 query never builds the propagator. A BooleanQuery gives its scoring clauses its own score mode (BooleanWeight.java:57-59) and calls their get() (BooleanScorerSupplier.java:426, :540). Executed on main and 10.6.0: BooleanQuery(SHOULD dmq(tie 0), SHOULD MatchAllDocsQuery) and BooleanQuery(MUST dmq(tie 0), MUST MatchAllDocsQuery), top-10, throw the same RuntimeException from :40; the same dmq(tie 0) at top level returns 1 hit, and so does BooleanQuery(MUST dmq(tie 0), FILTER MatchAllDocsQuery), which rewrites to that top-level query (BooleanQuery.java:388-392, :279-288).

Exposure and likelihood239 words
  • Who is hit. Code whose own extension does I/O in a clause's getMaxScore(NO_MORE_DOCS) (for example a custom scorer that bounds scores from doc values) and an error handler that catches IOException to fail a segment or shard cleanly: the failure arrives as an unchecked RuntimeException and bypasses that handler.
  • Reach. A DisjunctionMaxQuery with tie-breaker > 0, or with any tie-breaker as one of several scoring clauses of a BooleanQuery (executed); a top-level one with tie-breaker 0 uses DisjunctionMaxBulkScorer and is spared. LogOddsFusionQuery (10.5.0+) is hit too.
  • Which extension code. A custom Query/Scorer (the repro). By the same throws IOException signatures, also a custom DoubleValuesSource under FunctionScoreQuery, whose scorer's getMaxScore calls DoubleValues.getMaxScore (FunctionScoreQuery.java:262-264 in lucene/queries; DoubleValues.java:59), or a postings format whose ImpactsSource.getImpacts reads lazily (TermScorer.java:122-123, MaxScoreCache.java:96-109, index/ImpactsSource.java:49). Only the first was run.
  • Default vs opt-in. Default searcher, codec and directory; DisjunctionMaxQuery is a stock query, LogOddsFusionQuery exists from 10.5.0. The trigger is the custom code, a legal public extension point (getMaxScore declares IOException). No scale is needed: 1 document, 2 clauses.
  • Stock Lucene never reaches it. The in-tree impacts sources answer getMaxScore(NO_MORE_DOCS) from impacts that advanceShallow already decoded; composite scorers only delegate; other leaf scorers return constants.
  • Loud, contained. The search fails either way; only the outer exception type is wrong. No wrong or partial results.
  • Likelihood: contrived. No public report of it was found since 8.3.0 (2019).

Minimal repro

One JUnit test, single thread, deterministic: one empty document, a DisjunctionMaxQuery (tie-breaker 0.1) of MatchAllDocsQuery and a custom query whose scorer's getMaxScore throws IOException, and search(q, 10). It fails with assertions on and off on main and on the 10.6.0 jars.

TestDisjunctionMaxQueryMaxScoreIOException.java (one test, lucene/core)112 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import java.io.IOException;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDisjunctionMaxQueryMaxScoreIOException extends LuceneTestCase {

  public void testGetMaxScoreIOExceptionIsPropagated() throws Exception {
    Query failing =
        new Query() {
          @Override
          public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) {
            return new ConstantScoreWeight(this, boost) {
              @Override
              public ScorerSupplier scorerSupplier(LeafReaderContext context) {
                DocIdSetIterator it = DocIdSetIterator.all(context.reader().maxDoc());
                Scorer scorer =
                    new Scorer() {
                      @Override
                      public int docID() {
                        return it.docID();
                      }

                      @Override
                      public DocIdSetIterator iterator() {
                        return it;
                      }

                      @Override
                      public float score() {
                        return boost;
                      }

                      @Override
                      public float getMaxScore(int upTo) throws IOException {
                        throw new IOException("getMaxScore failed");
                      }
                    };
                return new ScorerSupplier() {
                  @Override
                  public Scorer get(long leadCost) {
                    return scorer;
                  }

                  @Override
                  public long cost() {
                    return it.cost();
                  }
                };
              }

              @Override
              public boolean isCacheable(LeafReaderContext ctx) {
                return false;
              }
            };
          }

          @Override
          public String toString(String field) {
            return "failing";
          }

          @Override
          public void visit(QueryVisitor visitor) {}

          @Override
          public boolean equals(Object other) {
            return this == other;
          }

          @Override
          public int hashCode() {
            return System.identityHashCode(this);
          }
        };
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(new Document());
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q = new DisjunctionMaxQuery(List.of(new MatchAllDocsQuery(), failing), 0.1f);
        // expected: the IOException from getMaxScore; actual: RuntimeException wrapping it
        expectThrows(IOException.class, () -> new IndexSearcher(reader).search(q, 10));
      }
    }
  }
}
Command
cd $LUCENE
cp $REPRO/TestDisjunctionMaxQueryMaxScoreIOException.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestDisjunctionMaxQueryMaxScoreIOException -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
search(q, 10) throws the clause's java.io.IOException: getMaxScore failed.
Observed
It throws java.lang.RuntimeException: java.io.IOException: getMaxScore failed, from DisjunctionScoreBlockBoundaryPropagator.java:40.

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

More files, environment, full output173 words

The custom query implements ScorerSupplier directly; its Weight extends ConstantScoreWeight, which is tagged @lucene.internal (ConstantScoreWeight.java:22-29), as is usual for a test in lucene/core. A variant that uses only public API (a plain Weight subclass with its own ScorerSupplier, the cross-model reviewer's own test) fails the same way on 5adff78bb168, assertions on and off. The custom query's scorer iterates all documents with a constant score and throws IOException("getMaxScore failed") from getMaxScore. The test draws no random values. It belongs in lucene/core/src/test/org/apache/lucene/search/, next to TestDisjunctionMaxQuery, and passes :lucene:core:check -x test.

Regression test: this same test.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); also fails on the 10.6.0 jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestDisjunctionMaxQueryMaxScoreIOException > testGetMaxScoreIOExceptionIsPropagated FAILED
    junit.framework.AssertionFailedError: Unexpected exception type, expected IOException but got java.lang.RuntimeException: java.io.IOException: getMaxScore failed
        at org.apache.lucene.search.TestDisjunctionMaxQueryMaxScoreIOException.testGetMaxScoreIOExceptionIsPropagated(TestDisjunctionMaxQueryMaxScoreIOException.java:108)

        Caused by:
        java.lang.RuntimeException: java.io.IOException: getMaxScore failed
            at org.apache.lucene.search.DisjunctionScoreBlockBoundaryPropagator.lambda$static$0(DisjunctionScoreBlockBoundaryPropagator.java:40)
            at java.base/java.util.Comparator.lambda$comparing$77a9974f$1(Comparator.java:473)
            ...
            at java.base/java.util.Arrays.sort(Arrays.java:1230)
            at org.apache.lucene.search.DisjunctionScoreBlockBoundaryPropagator.<init>(DisjunctionScoreBlockBoundaryPropagator.java:54)
            at org.apache.lucene.search.DisjunctionMaxScorer.<init>(DisjunctionMaxScorer.java:53)
            at org.apache.lucene.search.DisjunctionMaxQuery$DisjunctionMaxWeight$1.get(DisjunctionMaxQuery.java:158)
            ...
            at org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:624)

            Caused by:
            java.io.IOException: getMaxScore failed
                at org.apache.lucene.search.TestDisjunctionMaxQueryMaxScoreIOException$1$1$1.getMaxScore(TestDisjunctionMaxQueryMaxScoreIOException.java:60)
                at org.apache.lucene.search.DisjunctionScoreBlockBoundaryPropagator.lambda$static$0(DisjunctionScoreBlockBoundaryPropagator.java:38)

Why it looks unintended

  • Scorer.getMaxScore, IndexSearcher.search and the propagator's own constructor all declare IOException.
  • The identical getMaxScore call right after the sort throws IOException unchanged; only the copy inside the lambda wraps it.
  • 8.2.0 surfaced the IOException; the wrap came with LUCENE-8922, and no javadoc, CHANGES entry or test mentions it.
The full argument182 words
  • The declared contract. Scorer.getMaxScore(int) declares IOException (Scorer.java:79), as do IndexSearcher.search(Query, int) (IndexSearcher.java:623) and the propagator's constructor (DisjunctionScoreBlockBoundaryPropagator.java:49).
  • Same method, two answers. The constructor lets advanceShallow (:52) and the identical getMaxScore(NO_MORE_DOCS) call at :58 throw IOException unchanged; only the copy inside the comparator wraps it. A lambda cannot throw a checked exception, so the comparator had to adapt it somehow; nothing in the introducing PR, the javadoc or the tests suggests that changing the type was meant (an inference from the record).
  • History. Before the class existed, 8.2.0 surfaced the same failure as the declared IOException (thrown via DisjunctionMaxScorer.getMaxScore). The introducing PR (apache/lucene-solr#791) and its issue (#9965) never discuss exceptions; no javadoc, CHANGES entry or test documents the wrap.
  • Project practice. #12516 (its CHANGES entry: "Unwrap and throw execution exceptions cause when performing concurrent search") made concurrent search throw the original exception, as the sequential path does. lucene/MIGRATE.md:526-527 asks for UncheckedIOException for I/O left inside reader getters that no longer declare IOException (#16057): a precedent for the unchecked wrapper type, not a project-wide rule.
The devil's-advocate check132 words

The case for "intended": wrapping a checked exception in a Comparator is a common Java idiom; Query, Weight and Scorer are expert APIs; core wraps IOException in RuntimeException elsewhere (for example DirectReader's LongValues.get, util/packed/DirectReader.java:199-205); the cause is kept and the failure is loud. So this is at most a cleanup, and #12516 itself was filed under "Changes in runtime behavior", not "Bug Fixes".

Why it fails as an intent argument: those other wraps sit behind signatures that cannot throw IOException; here the enclosing constructor declares it and already throws it for the same call (:58). The argument bounds the value of a fix, not whether the behaviour is intended. The cross-model reviewer (OpenAI GPT-5.6) rated it moderate; every checker agreed it limits the priority only.

Suggested fix

Recommended Read the max scores before sorting, sort by the stored values

Read each clause's max score once, before the sort, and sort by the stored values (as MaxScoreBulkScorer does). The IOException then propagates as declared, and the sort no longer calls getMaxScore for each comparison. Validated: the repro passes with assertions on and off; 4 disjunction suites (65 tests) and :lucene:core:check -x test pass.

All options and trade-offs2 options ยท 481 words

Both options were validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03): stock, the repro fails with assertions on and off; with either fix it passes, on and off.

Read the max scores before sorting, sort by the stored values (validated by the checks) (recommended)

Replace MAX_SCORE_COMPARATOR with a loop that reads getMaxScore(NO_MORE_DOCS) once per clause into maxScores, then sort the scorers and their scores together with an InPlaceMergeSorter: by Float.compare of the stored score, then by cost, as today (both sorts are stable, so the order is unchanged). getMaxScore runs outside any lambda, so its IOException propagates as declared. The Arrays and Comparator imports go.

Scorer[] scorerArray = scorers.toArray(Scorer[]::new);
for (Scorer scorer : scorerArray) {
  scorer.advanceShallow(0);
}
float[] maxScoreArray = new float[scorerArray.length];
for (int i = 0; i < scorerArray.length; ++i) {
  maxScoreArray[i] = scorerArray[i].getMaxScore(DocIdSetIterator.NO_MORE_DOCS);
}
new InPlaceMergeSorter() {
  @Override
  protected int compare(int i, int j) {
    int cmp = Float.compare(maxScoreArray[i], maxScoreArray[j]);
    if (cmp == 0) {
      cmp = Long.compare(scorerArray[i].iterator().cost(), scorerArray[j].iterator().cost());
    }
    return cmp;
  }

  @Override
  protected void swap(int i, int j) {
    ArrayUtil.swap(scorerArray, i, j);
    float tmp = maxScoreArray[i];
    maxScoreArray[i] = maxScoreArray[j];
    maxScoreArray[j] = tmp;
  }
}.sort(0, scorerArray.length);
this.scorers = scorerArray;
this.maxScores = maxScoreArray;

Trade-offs

A slightly larger diff than the alternative (+29/-20 lines against +7/-2). It changes how often and when getMaxScore is called: once per clause, before the sort, instead of during the sort (in each comparison the clause takes part in) plus once more after it; the regression test covers the exception path. No benchmark was run.

What the checks validated

  • Stock 5adff78bb168: the repro fails through gradle with assertions on and off; with this fix it passes, on and off.
  • With the fix, the repro plus TestDisjunctionMaxQuery, TestLogOddsFusionQuery and TestDisjunctionScoreBlockBoundaryPropagator: 65 tests, 0 failures, 0 skipped.
  • :lucene:core:check -x test (format, lint, forbidden APIs) with the fix and the test: passes.

Throw UncheckedIOException in the comparator, rethrow its cause around the sort (validated by the checks)

The smallest change: in the lambda, throw new UncheckedIOException(e) instead of new RuntimeException(e), and wrap Arrays.sort in try { ... } catch (UncheckedIOException e) { throw e.getCause(); }.

-                  throw new RuntimeException(e);
+                  throw new UncheckedIOException(e);
 ...
-    Arrays.sort(this.scorers, MAX_SCORE_COMPARATOR);
+    try {
+      Arrays.sort(this.scorers, MAX_SCORE_COMPARATOR);
+    } catch (UncheckedIOException e) {
+      throw e.getCause();
+    }

Trade-offs

Keeps the comparator and its repeated getMaxScore calls. An UncheckedIOException that a scorer itself throws during the sort would also be rethrown as its checked cause, a small behaviour change of its own; the recommended fix has no such collision.

What the checks validated

  • The repro passes through gradle with assertions on and off; the repro plus the same 3 disjunction suites: 65 tests, 0 failures (:lucene:core:check not run for this option). The hunt's own runs of this change agree (63 and 64 tests in their suite runs).

Downstream note

Downstream products are affected only if they run their own or plugin extension code (a scorer, value source or postings format) that does I/O in getMaxScore under these queries and handle IOException and RuntimeException differently; no query or document can trigger it.

How it was found

A proof-obligation audit of DisjunctionScoreBlockBoundaryPropagator's contract asked whether every IOException its constructor can meet reaches the caller as declared, and found the one inside the sort comparator does not. A JUnit test then witnessed it on main; independent checks reproduced it, two of them with their own tests.

The method in full116 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The obligation: the constructor declares IOException, so an IOException from any scorer call it makes should reach the caller as IOException. advanceShallow (:52) and the post-sort getMaxScore (:58) meet it; the comparator's getMaxScore (:38-40) does not.
  • A JUnit test witnessed it on main (assertions on and off) and on 10.6.0 and 10.0.0. An independent reproducer and an executed review by another model family (OpenAI GPT-5.6) wrote their own tests from the claim alone; a devil's-advocate verifier and the adjudication re-ran the finder's test; the one-test repro shipped here was then cut to its minimum.

Prior reports, history and checks

Prior reports

No prior report: three scripted prior-art runs, 31 manual GitHub searches and 11 web searches over issues, PRs, commits, the mailing lists, JIRA and downstream projects, plus the changed files of all open PRs (2026-11-03), rechecked the same day.

Searches3 searches

No report found in the documented searches: zero hits for DisjunctionScoreBlockBoundaryPropagator or MAX_SCORE_COMPARATOR in apache/lucene issues and PRs, and none reports a top-k DisjunctionMaxQuery or LogOddsFusionQuery search throwing RuntimeException instead of IOException. Related, not this bug: #9965 / apache/lucene-solr#791 (LUCENE-8922, introduced the class and the wrap without discussing it), #13795 and #16291 (edit only the cost tie-break), open PR #16410 (removes LogOddsFusionScorer's use of the propagator, which would leave only the DisjunctionMaxQuery path, without mentioning the wrap), open PR #15659 (touches only DisjunctionMaxBulkScorer), elastic/elasticsearch#130239 (another bug, #14857 / #14858); background #12516 and #8851 (LUCENE-7800). Gap: GitHub search does not index PR review comments; the issues@ list, which mirrors them, was searched instead.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA archive; web2026-11-03three scripted prior-report search runs (51 queries each), 31 manual GitHub searches, 11 web searches, 9 direct readsonly the related items above
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRs and of the 42 PRs updated since 2026-11-01none changes the propagator; #16410 removes the LogOddsFusionScorer use only
Recheck (GitHub)2026-11-03class, method and symptom queries; PRs updated since 2026-11-01; upstream file historynothing new; upstream main 1c2072f0284 has the identical file

History

Introduced by LUCENE-8922 (1ea8419336a, 2019-07-24), which created the propagator with the wrapping comparator; first released in 8.3.0. Executed on sampled releases with equivalent tests: 8.2.0 passes; 8.3.0 to 10.6.0 and main fail. LogOddsFusionQuery shares the path from 10.5.0. Not fixed upstream.

Versions tested6 rows

Introduced by 1ea8419336a, "LUCENE-8922: Better impacts for DisjunctionMaxQuery. (#791)" (Adrien Grand, 2019-07-24; PR apache/lucene-solr#791, issue LUCENE-8922 = #9965). It created DisjunctionScoreBlockBoundaryPropagator with the wrapping comparator and made DisjunctionMaxScorer build it in TOP_SCORES mode; git blame -w -M on 5adff78bb168 gives the try/catch/throw (:37-41) to it. 8.x shipped the branch_8x cherry-pick 967e1c82be9. git tag --contains puts the wrap in every tagged release from 8.3.0 to 8.8.1 and from 9.0.0 to 10.6.0 (the history clone has no tags for 8.8.2 to 8.11.4). The version runs used equivalent tests: the finder's earlier test, its copy written against the older Scorer(Weight) API before 10.0, or the independent reproducer's test (10.5.2); the shipped test itself ran on main and 10.6.0. Later edits to the file (a reformat, #13795, #16291) did not touch the catch. LogOddsFusionScorer builds the same propagator from its first release, 10.5.0 (read). Upstream main 1c2072f0284 (2026-11-02) has the identical file.

Versions and branches executed, with results
Version or commitVerdictResult
8.2.0 last beforethe IOException reaches the caller (thrown via DisjunctionMaxScorer.getMaxScore)
1ea8419336a (LUCENE-8922) introducedadds the propagator and the wrapping comparator (read)
8.3.0 first: 8.3.0RuntimeException(IOException) at DisjunctionScoreBlockBoundaryPropagator.java:40
8.11.4, 9.0.0, 9.12.3 affectedfail, same line (API copy of the test)
10.0.0, 10.5.2, 10.6.0 affectedfail, same line
main 5adff78bb168 affectedfail, assertions on and off; passes with either fix

Independent checks

An independent reproducer, an executed review by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (medium confidence, priority Low). This doc's gradle runs reproduced it on main and validated both fixes.

Checks and verdicts281 words

The cross-model review ran on main ea57360912ed, whose relevant files equal 5adff78bb168's, and its test was re-run on 5adff78bb168 with the same result; every other check ran on 5adff78bb168 or a release.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOne-test JUnit fails on main (on, off), 10.6.0 and 10.0.0 at :40; its probe ran the controls (2nd-call failure, advanceShallow failure, COMPLETE mode, LogOddsFusionQuery).
independent reproducer real, LowOwn test (bare Scorer subclass) fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3, always at :40.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own public-API 3-test JUnit: the first-call failure gets RuntimeException, both controls behave; with the UncheckedIOException fix 3 of 3 pass; intended-behaviour argument rated moderate.
devil's-advocate verifier real on intent, mediumRe-ran the finder's test on main (on, off); built the "common idiom, expert API" case and showed every other core wrap sits behind a signature that cannot throw; no in-tree trigger.
adjudication confirmed (medium); LowRe-ran the test with a new seed, on and off: fail; every argument against is about reach, not intent.
separate re-run, audit and final repro check reproduced; confirmed, LowFresh seeds on main (on, off) and the 10.6.0 jars fail; the fix passes; class lowered to minor.
fact check of this doc (and a second one by another model family, OpenAI GPT-5.6) real; scope correctedRan the shipped test through gradle on main (on, off) and on the 10.6.0 jars (on, off): fail at :40; with the recommended fix, 65 tests pass. Its probe found the nested tie-breaker-0 reach, now in the doc.

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

Finding record

LFV-1222, snapshot of 2026-11-06

Registry title
A top-k search of a DisjunctionMaxQuery or LogOddsFusionQuery throws a plain RuntimeException instead of the declared IOException when a clause's Scorer.getMaxScore throws IOException, because DisjunctionScoreBlockBoundaryPropagator's sort comparator wraps it (since 8.3.0)
Area
core / search: disjunction scoring (DisjunctionScoreBlockBoundaryPropagator, built by DisjunctionMaxScorer and LogOddsFusionScorer for top-k searches)
Priority
Low, rank 839: Latent exception-type inconsistency: a top-k DisjunctionMaxQuery or LogOddsFusionQuery search (a top-level tie-breaker-0 DisjunctionMaxQuery is spared) throws a RuntimeException wrapping the IOException instead of the declared IOException when a clause's getMaxScore fails, since 8.3.0 (10.5.0 for LogOddsFusionQuery). No in-tree scorer does I/O there, so only custom extension code (such as a custom scorer) triggers it, and the search still fails with the IOException as the cause.
Impact class
minor
Impact note
Only applications that put a custom or plugin Query whose Scorer.getMaxScore(NO_MORE_DOCS) can throw IOException (for example one that reads data there) under a DisjunctionMaxQuery (tie-breaker > 0 at top level, any tie-breaker next to another scoring clause of a BooleanQuery) or a LogOddsFusionQuery, in a top-k (TOP_SCORES) search with the default searcher, are hit, with -ea and -da and no scale: when that call fails during the clause sort, IndexSearcher.search throws an unchecked RuntimeException with the IOException as its cause instead of the declared IOException, so code that catches IOException to fail a segment or shard cleanly misses it. The search still fails loudly, no result is wrong, and no in-tree scorer does I/O in getMaxScore(NO_MORE_DOCS) (impacts are already decoded by advanceShallow), so stock Lucene never reaches it. (the final repro check lowered the class from wrong error to minor: latent, only custom or plugin extension code can trigger it, the search still fails with the IOException as the cause, no result is wrong)
Summary
You run a top-k search, IndexSearcher.search(q, 10), of a DisjunctionMaxQuery with tie-breaker 0.1 whose second clause is your own query whose Scorer.getMaxScore throws IOException, and expect that IOException: Scorer.getMaxScore and IndexSearcher.search both declare it, and the same failure on a later, identical getMaxScore call does surface as IOException. Instead the search throws java.lang.RuntimeException wrapping it, from DisjunctionScoreBlockBoundaryPropagator.java:40, because the propagator sorts the clauses with a Comparator whose key extractor calls getMaxScore(NO_MORE_DOCS) and turns the IOException into a RuntimeException (a lambda cannot throw a checked exception). LogOddsFusionQuery builds the same propagator and behaves the same; a search that does not prune (COMPLETE score mode) is unaffected.
Introduced
1ea8419336a6806463fd9dc83dc77e41a5475649 (master, 'LUCENE-8922: Better impacts for DisjunctionMaxQuery. (#791)', by Adrien Grand (jpountz), apache/lucene-solr#791, issue LUCENE-8922 = apache/lucene#9965); the 8.x code that shipped is the branch_8x cherry-pick 967e1c82be9 plus 8df37aaf2c5 ('LUCENE-8922: 8.x adjustments.', which does not touch the comparator). The commit created DisjunctionScoreBlockBoundaryPropagator with MAX_SCORE_COMPARATOR, whose key extractor calls getMaxScore(NO_MORE_DOCS) and does 'catch (IOException e) { throw new RuntimeException(e); }', and made DisjunctionMaxScorer build the propagator in TOP_SCORES mode [read: git blame -w -M on 5adff78bb168 gives the try/catch/throw at :37-41 to it] [exec: 8.2.0 passes, 8.3.0 fails]. Neither the PR review nor the issue comments mention the wrap. (2019-07-24 (author date = commit date, 15:12:57+02:00; PR merged 13:12:58Z); first release 8.3.0 (tagged 2019-10-24, LUCENE-8922 listed under 8.3.0 Optimizations))
First affected version
8.3.0 [exec] (8.2.0 has no DisjunctionScoreBlockBoundaryPropagator, and the same program gets the IOException there, thrown via DisjunctionMaxScorer.getMaxScore [exec]: a change of behaviour that first shipped in 8.3.0)
Affected versions
every release from 8.3.0 through 10.6.0 (4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT) through DisjunctionMaxQuery, and through LogOddsFusionQuery from its first release, 10.5.0 [read: releases/lucene/10.5.0 and 10.6.0 LogOddsFusionScorer.java:104]. Executed and failing with RuntimeException(IOException) at DisjunctionScoreBlockBoundaryPropagator.java:40: 8.3.0, 8.11.4, 9.0.0 and 9.12.3 with a copy of the test written against the pre-10.0 Scorer(Weight) API (main() mode, -ea), 10.0.0 and 10.6.0 with the finder's JUnit test, 10.6.0, 10.5.2 and 9.12.3 with the independent reproduction's own test, main with -ea and -da (finder, the independent reproduction, the intended-behaviour check, adjudication, registration check) and the released 10.6.0 jars with -da (registration check) [exec]. By git tag --contains, the branch_8x commit is in all 14 releases/lucene-solr tags 8.3.0..8.8.1 of the history clone and 1ea8419336a in all 34 releases/lucene tags 9.0.0..10.6.0 [read]; 8.8.2..8.11.3 have no tag there, the fix site is unchanged since 2019 apart from a reformat and two cosmetic edits to the secondary cost comparator, and 8.11.4 fails [exec]. Upstream main 1c2072f0284 (2026-11-02) has the same file blob (ba9827d20cc) [read]. Not affected: 8.2.0 and earlier (8.2.0 executed: pass). Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's run and the intended-behaviour check's and the adjudication's searches; rechecked the same day): 204 recorded queries (three scripted prior-report searches of 51 queries each over GitHub issues and PRs, commits, downstream projects, dev@, java-user@, issues@, commits@, the JIRA archive and the web; 31 manual GitHub searches; 11 web searches; 9 direct reads of items, the JIRA archive, the list archive and the git history), the changed files of all 409 open apache/lucene PRs and of the 42 PRs updated since 2026-11-01 (re-checked), and the upstream commit history of the file with author and committer dates. Zero hits for DisjunctionScoreBlockBoundaryPropagator or MAX_SCORE_COMPARATOR in apache/lucene issues and PRs; no hit reports the wrap or its symptom. Closest, not this bug: #9965 / LUCENE-8922 and apache/lucene-solr#791 (introduced the class and the wrap; exceptions never discussed), #13795 and #16291 (edit only the secondary cost comparator), open PR #16410 by jaepil (removes LogOddsFusionScorer's use of the propagator, which would narrow the trigger to DisjunctionMaxQuery, but neither reports nor fixes the wrap), open PR #15659 (touches only DisjunctionMaxBulkScorer), elastic/elasticsearch#130239 (a stack frame in the propagator's advanceShallow; another bug, #14857 / #14858); background: #12516 and #8851 / LUCENE-7800. Not fixed: upstream main 1c2072f0284 (2026-11-02) has the identical file blob.
Lucene commit tested
main 5adff78bb168 (the one-test repro through gradle in a clean scratch worktree, assertions on and off, unfixed and with each of the two fixes; with either fix the repro, TestDisjunctionMaxQuery, TestLogOddsFusionQuery and TestDisjunctionScoreBlockBoundaryPropagator pass, and :lucene:core:check -x test passes with the recommended one, 2026-11-03); the released 10.6.0 jars (the same test, assertions on and off, in the doc's fact check). Earlier checks ran the same or equivalent tests on 8.2.0 (pass) and 8.3.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main (fail); the cross-model review ran on main ea57360912ed, whose relevant files equal 5adff78bb168's, and was re-run on 5adff78bb168.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionScoreBlockBoundaryPropagator.java:34-43 (at 5adff78bb168) (MAX_SCORE_COMPARATOR: getMaxScore(NO_MORE_DOCS) in the key extractor, 'throw new RuntimeException(e)' at :40), :49 (constructor declares IOException), :52 (advanceShallow, IOException passes), :54 (Arrays.sort with the comparator), :58 (the identical getMaxScore(NO_MORE_DOCS) call, IOException passes)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:52-53 (at 5adff78bb168) and LogOddsFusionScorer.java:103-104 (both build the propagator when scoreMode == TOP_SCORES)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:148-171 (at 5adff78bb168) (ScorerSupplier.get builds DisjunctionMaxScorer, :158; bulkScorer uses DisjunctionMaxBulkScorer, without the propagator, only when tieBreakerMultiplier == 0 and TOP_SCORES, :163-170)
  • lucene/core/src/java/org/apache/lucene/search/Scorer.java:71 (at 5adff78bb168) (advanceShallow throws IOException), :79 (getMaxScore throws IOException); IndexSearcher.java:623 (search(Query, int) throws IOException); Weight.java:202-207 (DefaultScorerSupplier, @lucene.internal)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionScoreBlockBoundaryPropagator.java:34-43 (at 5adff78bb168) (the comparator, throw at :40), :49-60 (constructor; :52 advanceShallow, :54 Arrays.sort, :58 the identical getMaxScore call)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:52-53; (at 5adff78bb168) LogOddsFusionScorer.java:103-104 (built only in TOP_SCORES)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:153-159 (at 5adff78bb168) (ScorerSupplier.get builds DisjunctionMaxScorer for any tie-breaker), :162-170 (bulkScorer: DisjunctionMaxBulkScorer only for tie-breaker 0 in TOP_SCORES); BooleanWeight.java:57-59 (scoring clauses get the parent's score mode); BooleanScorerSupplier.java:426, :540 (child get()); BooleanQuery.java:388-392 (drops a MatchAllDocsQuery FILTER next to a MUST), :279-288 (1-clause rewrite)
  • lucene/core/src/java/org/apache/lucene/search/Scorer.java:71 (at 5adff78bb168) (advanceShallow throws IOException), :79 (getMaxScore throws IOException)
Who is affected
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:55 (at 5adff78bb168) (public constructor taking a DoubleValuesSource), :262-264 (getMaxScore calls scores.getMaxScore); lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:59; TermScorer.java:122-123; MaxScoreCache.java:96-109; lucene/core/src/java/org/apache/lucene/index/ImpactsSource.java:49
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Scorer.java:79; (at 5adff78bb168) IndexSearcher.java:617-624; DisjunctionScoreBlockBoundaryPropagator.java:49-60
  • lucene/CHANGES.txt:3466-3469 (at 5adff78bb168) (GITHUB#12516 under 'Changes in runtime behavior')
  • lucene/MIGRATE.md:517-527 (at 5adff78bb168) (GITHUB#16057: UncheckedIOException for I/O left inside cheap reader getters that no longer throw)
  • lucene/core/src/java/org/apache/lucene/util/packed/DirectReader.java:199-205 (at 5adff78bb168) (a wrap behind LongValues.get, which cannot throw)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java:487-492 (at 5adff78bb168) (sorts by stored maxWindowScore / cost)
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionScoreBlockBoundaryPropagator.java:34-60 (at 5adff78bb168)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 5adff78bb168 and the executed repro, and re-rendered after the fact check (scope and fix labels); the PNG is the summary still. The fix panel shows the smallest fix, because the recommended one is too long to draw verbatim; both are in Suggested fix.

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.