All findings · Lucene finding · LFV-633 · confusionmatrix-timed-out-classification-blocks-later-docs

ConfusionMatrixGenerator never stops a classification that exceeds its 5 s limit, so the documents after it time out too

ConfusionMatrixGenerator waits at most 5 s per classification but never stops a slower one, which keeps its only worker thread, so the next documents time out in the queue behind it. With 3 documents, one 12 s classification drops an instant one; a hung one drops both.

Summary

Priority
Low, rank 814In the opt-in classification evaluation utility, a stuck or slow classification blocks the single worker and skews the confusion matrix, which fits the executor stuck-task rows after the OfflineSorter hang LFV-384.
Impact
minor
Since
6.0.0 (LUCENE-6479, 2015)introduced 2015-09-02 · over 11 years
Affected
every release 6.0.0 to 10.6.0, and main
Status
documented
Found by
non-formal: code reading of the timeout path, then a deterministic 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

What ConfusionMatrixGenerator does

  1. getConfusionMatrix measures a classifier: for every document that has a class label it asks the classifier for a class from the document's text and counts the pair (true class, assigned class).
  2. Each classification runs on the generator's own single worker thread, and the generator waits at most 5 s for it; a slower one is counted as a timeout and skipped.
  3. getNumberOfEvaluatedDocs() and getAvgClassificationTime() report how many documents were counted and how long a classification took on average.
Diagram of the bug: ConfusionMatrixGenerator classifies labelled documents one after another on a single worker thread and waits at most 5 seconds for each. The first classification runs longer than 5 seconds and is counted as a timeout, but it keeps the worker, so the next document's classification, which would take no time, waits in the queue behind it, runs out of its own 5 seconds and is counted as a timeout too. With 3 documents and a 12 second first classification, 1 of the 2 instant documents is counted; with a hung one, none.
Documents queue for the generator's single worker thread: the first classification runs past its 5 s limit and keeps the worker, so the next document waits behind it, runs out of its own 5 s and is counted as a timeout too. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You evaluate a classifier with getConfusionMatrix(reader, classifier, "class", "text", -1) (no overall time budget) on 3 labelled documents: the first takes 12 s to classify, the other two are classified instantly and correctly.
  2. The generator runs each classification on its single worker thread and waits at most 5 s ("fail if classification takes more than 5s"); on a timeout it adds 5,000 ms and moves on. You expect only the slow document to be skipped: 2 documents evaluated.
  3. But the slow classification is never stopped and keeps the worker. The second document's task waits in the queue behind it, its 5 s run out before it even starts, and it is counted as a timeout too.
  4. You get 1 of the 2 instant documents counted and an average classification time of 11,999 ms. If the first classification hangs: 0 of 2, an empty matrix and an average of Infinity, after 15 s. No error; only a lower getNumberOfEvaluatedDocs() hints at it.

Who is affected

Code that calls this opt-in evaluation utility of the classification module (in Lucene only tests do; no use found in Solr, Elasticsearch or OpenSearch) with no overall budget or one above 5 s, and a classifier that takes more than about 10 s on some document or hangs. Indexes, searches and the classifier's own answers are not affected.

Mechanism, terms and numbers746 words

ConfusionMatrixGenerator.getConfusionMatrix evaluates a Classifier by classifying every labelled document of an index on a one-thread executor, waiting at most 5 s for each ("fail if classification takes more than 5s"). On a timeout it adds 5,000 ms to the running time and moves on, but it never cancels or abandons the timed-out task. That task keeps the only worker, so the next documents queue behind it, use up their own 5 s in the queue and are counted as timeouts too, although their own classification takes no time. On main 645d4fe14a20, 3 documents with a 12 s first classification give 1 of 2 instant documents counted and an average classification time of 11,999 ms; a hung first classification gives 0 of 2, an empty matrix and an average of Infinity. There is no error; only a lower getNumberOfEvaluatedDocs() hints at the loss, and when the stuck classification outlives the call, it and the documents queued behind it are still classified afterwards, with the results thrown away. Every release with the class, 6.0.0 to 10.6.0, and main is affected. It is a low-priority robustness and accounting bug in an opt-in evaluation utility: no index, search or classifier answer is affected.

The bug (main 645d4fe14a20)

  1. One worker. getConfusionMatrix creates Executors.newFixedThreadPool(1, new NamedThreadFactory("confusion-matrix-gen-")) (ConfusionMatrixGenerator.java:71-72); the thread is non-daemon (NamedThreadFactory.java:73).
  2. A 5 s wait per document, the Future dropped. For every labelled document: // fail if classification takes more than 5s, then executorService.submit(() -> classifier.assignClass(text)).get(5, TimeUnit.SECONDS), and time += end - start (:100-107).
  3. Timeout = skip and continue, on the same executor. catch (TimeoutException _) { time += 5000; } (:140-142). Nothing cancels the task or replaces the executor.
  4. So the next task queues. The timed-out task still holds the only worker; the next document's task is queued behind it, and its get(5, SECONDS) counts queue time. If the stuck task needs about 5 s more, that document times out without ever starting, and so on.
  5. The overall budget acts only when positive. if (timeoutMilliseconds > 0 && time >= timeoutMilliseconds) break; (:87-89). With no budget every document is visited; with a budget of at most 5,000 ms the loop stops after the first timeout anyway.
  6. Result and afterwards. new ConfusionMatrix(counts, time / counter, counter) (:149), so the average is Infinity when timeouts were charged but nothing was counted (NaN if nothing added time at all) and includes queue waits otherwise. finally shutdown() (:150-151) does not wait, so when the stuck classification outlives the call, it and the tasks still queued run afterwards, and their results go nowhere.

Timeline of the minimal repro (doc 0 hangs, doc 1 instant, no budget)

At 0 s doc 0 starts and blocks. At 5 s its wait expires (time = 5000) and doc 1 is queued behind it. At 10 s doc 1's wait expires while it is still queued (time = 10000). The call returns 0 evaluated documents, an empty matrix and 10000 / 0 = Infinity.

The numbers (3 documents: slow, b, c; no budget; main 645d4fe14a20)

first classificationcounted (of the 2 instant documents)matrixaverage timecall returns after
instant (control)2 of 2 (3 evaluated)all three0.3 ms62 ms
takes 12 s, then returns1 of 2{c={c=1}}11,999 ms12,001 ms
hangs0 of 2{}Infinity15,002 ms

In the 12 s case b queues at 5 s and times out at 10 s; c is queued at 10 s; at 12 s the slow task ends, b runs (its result is discarded) and c returns inside its own 5 s, so only c is counted, and the running time of about 12,000 ms is divided by 1.

Reach rule (from the code, with no overall budget): while the slow classification runs, each later labelled document with text is submitted when the previous wait times out, so the k-th is submitted about 5k s after the slow one started and times out if the slow one is still running 5 s later. A classification of d seconds therefore loses about floor(d/5) - 1 later documents when d > 10 s and none when d < 10 s (near an exact multiple of 5 s, scheduling decides); verifiers ran it at 7 s (none lost) and 12 s (one lost). One that never returns loses every later labelled document with text.

Exposure and likelihood268 words
  • Who calls it. ConfusionMatrixGenerator is the classification module's evaluation utility ("Utilities for evaluation, data preparation, etc."). In Lucene only 8 test classes call it; a read-only code search found no use in Solr, Elasticsearch or OpenSearch.
  • What it takes. One classification slower than about 10 s, or one that hangs (a slow custom or remote classifier, a blocked lock), and no overall budget (timeoutMilliseconds <= 0, as 13 of the module's 15 test calls use) or one above 5 s. The only timings on record are dev@ CI failure mails of 2017-2018 for a classifier performance test that #9270 later relaxed: on slow CI machines the whole evaluation of a 100-document test index took 61-153 s, about 0.6-1.5 s per document. That is weak evidence about classifiers in general, but nothing near 10 s per document. Likelihood: rare.
  • What goes wrong. A smaller evaluation sample, biased by hit order (the documents right after a slow one drop out), with no error; an average classification time that includes queue waits or is Infinity; classification work already counted as timed out keeps running after the call returns.
  • What does not. Nothing is written (the index is only read), the counted cells are correct, getNumberOfEvaluatedDocs() reports the lower count (since 6.1.0), and the overall budget still stops the loop when set, as before (it is checked before each document, so a call can run up to one 5 s wait past it, bug or not). With a budget of at most 5,000 ms there is no extra loss.
  • Priority. Low: a robustness and accounting bug in an evaluation helper.

Minimal repro

One JUnit class, one method, public API only: two labelled documents, a classifier that blocks on a latch for doc 0 and answers doc 1 at once, no overall budget, and an assertion that 1 document was evaluated. It takes about 10 s (two hard-coded 5 s waits) and fails with assertions on and off.

TestConfusionMatrixTimedOutClassification.java (one test class)83 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.classification.utils;

import java.util.List;
import java.util.concurrent.CountDownLatch;
import org.apache.lucene.classification.ClassificationResult;
import org.apache.lucene.classification.Classifier;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BytesRef;

public class TestConfusionMatrixTimedOutClassification extends LuceneTestCase {

  public void testStuckClassificationDoesNotTimeOutNextDocument() throws Exception {
    CountDownLatch release = new CountDownLatch(1);
    Classifier<BytesRef> classifier =
        new Classifier<>() {
          @Override
          public ClassificationResult<BytesRef> assignClass(String text) {
            if (text.equals("stuck")) {
              try {
                release.await(); // never finishes within the generator's 5 s limit
              } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
              }
            }
            return new ClassificationResult<>(new BytesRef(text), 1.0);
          }

          @Override
          public List<ClassificationResult<BytesRef>> getClasses(String text) {
            throw new UnsupportedOperationException();
          }

          @Override
          public List<ClassificationResult<BytesRef>> getClasses(String text, int max) {
            throw new UnsupportedOperationException();
          }
        };
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (String text : new String[] {"stuck", "b"}) { // doc 0 hangs, doc 1 is instant
          Document doc = new Document();
          doc.add(new StringField("class", "b", Field.Store.YES));
          doc.add(new StringField("text", text, Field.Store.YES));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        ConfusionMatrixGenerator.ConfusionMatrix matrix =
            ConfusionMatrixGenerator.getConfusionMatrix(reader, classifier, "class", "text", -1);
        // expected: only doc 0 times out, so 1 doc is evaluated; actual: doc 1 times out too (0)
        assertEquals("evaluated documents", 1, matrix.getNumberOfEvaluatedDocs());
      } finally {
        release.countDown();
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:classification:test --tests org.apache.lucene.classification.utils.TestConfusionMatrixTimedOutClassification -Ptests.seed=DEADBEEF
Expected
Only doc 0 times out, so getNumberOfEvaluatedDocs() is 1.
Observed
evaluated documents expected:<1> but was:<0>: doc 1 waited behind doc 0's stuck task and timed out too.

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

More files, environment, full output330 words

The class sits in org.apache.lucene.classification.utils, next to TestConfusionMatrixGenerator, and uses nothing package-private. Smallest input: two documents in a ByteBuffersDirectory with the default codec and StringFields, so the input is fixed (the test framework still picks its usual randomized test settings, such as codec=Asserting(Lucene104) and a random similarity; they do not change the outcome, and the test failed with every seed tried). Doc 0's text stuck makes the test's classifier wait on a latch (this is the slow classification itself, not test synchronization); doc 1's text b is classified at once and correctly. timeoutMilliseconds = -1, so only the per-document 5 s limit acts. The latch is released in finally, so the worker ends and the suite reports no thread leak.

Regression test: this same test with one change for Lucene's build: catch (InterruptedException e) becomes catch (InterruptedException _), because ecjLintTest rejects an unused exception parameter. That form fails on stock 645d4fe14a20 (assertions on and off) and passes with the recommended fix, together with the module's lint checks.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0: fails (fact check, assertions on and off); 10.5.2 and 9.12.3: fail (earlier tests of the same case); with the recommended fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; no platform-specific dependency is apparent)
Command (environment)
cp $REPRO/TestConfusionMatrixTimedOutClassification.java $LUCENE/lucene/classification/src/test/org/apache/lucene/classification/utils/
cd $LUCENE && ./gradlew :lucene:classification:test --tests org.apache.lucene.classification.utils.TestConfusionMatrixTimedOutClassification -Ptests.seed=DEADBEEF
# about 10 s to fail; assertions off fails the same way: -Ptests.asserts=false (the recorded runs added --offline --no-daemon)

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestConfusionMatrixTimedOutClassification > testStuckClassificationDoesNotTimeOutNextDocument FAILED
    java.lang.AssertionError: evaluated documents expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:662B61BD54B6EC4A]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.classification.utils.TestConfusionMatrixTimedOutClassification.testStuckClassificationDoesNotTimeOutNextDocument(TestConfusionMatrixTimedOutClassification.java:77)

# a 3-document main() check (not the repro), 645d4fe14a20, -ea, selected fields
control(all instant): elapsedMs=62 matrix={a={a=1}, b={b=1}, c={c=1}} instantCounted=2/2 getNumberOfEvaluatedDocs=3
scenario HUNG:   elapsedMs=15002 matrix={} instantCounted=0/2 getNumberOfEvaluatedDocs=0 getAvgClassificationTime=Infinity callsAfterRelease=[slow, b, c]
scenario SLOW12: elapsedMs=12001 matrix={c={c=1}} instantCounted=1/2 getNumberOfEvaluatedDocs=1 getAvgClassificationTime=11999.0
RESULT bug-reproduced: instant documents counted: HUNG 0/2, SLOW12 1/2

Why it looks unintended

  • The code says a timeout costs one document: "fail if classification takes more than 5s", then it charges 5,000 ms and moves on.
  • The dropped documents are not slow; they only waited behind another one. A legal 12 s classification is enough.
  • Work already counted as timed out still runs after the call returns, and its result is thrown away.
The full argument202 words
  • Code intent. The comment // fail if classification takes more than 5s (ConfusionMatrixGenerator.java:100) and the catch branch that adds 5,000 ms and continues with the next document (:140-142) only make sense if a timeout costs that one document.
  • The dropped documents take no time. They time out only because they waited behind another document's task; no broken classifier is needed (12 s and then a normal return is enough).
  • The skipped work still runs. In the hung case the calls after release are [slow, b, c]: b and c, already counted as timed out, are classified after the call returned, and the results are discarded.
  • Nothing endorses it. No test covers a timed-out classification (the module's tests use fast classifiers and no budget), there is no CHANGES or MIGRATE entry, and the issue that added the timeout (#7538) has only commit notices and one thank-you comment. This shows only that nothing documents the behaviour as intended, not what the author meant.
  • Code intent, not contract: the per-document 5 s limit is in the code only; the javadoc documents only the overall timeoutMilliseconds and the number of evaluated documents. The 12 s case shows the effect most clearly.
The devil's-advocate check168 words

The strongest case for "intended" (a devil's-advocate verifier and the cross-model review lenses): an experimental, best-effort evaluation utility whose javadoc promises only the overall budget (@param timeoutMilliseconds timeout to wait before stopping creating the confusion matrix) and "the no. of documents evaluated", so a partial matrix is a visible, allowed outcome; Future.get(timeout) only bounds the wait; the single worker serialises calls into a classifier with no thread-safety contract (an effect of the code; nothing records it as a goal); Lucene's NIOFSDirectory and MMapDirectory javadocs warn that Thread#interrupt() or Future#cancel(boolean) during I/O can close the channel, so interrupting the stuck task is risky; and a classifier that hangs is arguably broken.

Why it fails: "fail if classification takes more than 5s" cannot mean that documents taking no time fail; a finite 12 s classification triggers it; and the interrupt concern limits how to fix it, not whether it is a bug (the recommended fix does not interrupt). All verifiers returned "real"; the adjudication confirmed with high confidence.

Suggested fix

Recommended After a timeout, leave the stuck task alone and continue on a fresh worker

After a timeout, leave the stuck classification running (no interrupt) and continue on a fresh single-thread executor, so later documents no longer queue behind it (+10/-2 lines). Validated: the repro, the module's tests (43, one @Monster skipped) and lint pass; the 3-document check counts 2 of 2 in 5 s. Trade-off: calls may overlap, and classifiers have no thread-safety contract.

} catch (TimeoutException _) {
  // add classification timeout
  time += 5000;
  // the timed-out classification still holds the only worker: leave it to finish on
  // its own (no interrupt, it may be reading the index) and give the next documents a
  // fresh worker, so that they do not wait behind it and time out as well
  executorService.shutdown();
  executorService = newExecutor();
}
...
private static ExecutorService newExecutor() {
  return Executors.newFixedThreadPool(1, new NamedThreadFactory("confusion-matrix-gen-"));
}
All options and trade-offs5 options ยท 787 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20. Every fix that keeps the per-document limit either interrupts the stuck classification or lets it overlap with the next ones; the recommended one takes the second route. Two non-overlapping alternatives and a complement were not built.

After a timeout, leave the stuck task alone and continue on a fresh worker (validated by the checks) (recommended)

In the TimeoutException branch, shutdown() the executor whose worker is stuck (it accepts no more work and its thread ends when the stuck classification returns; nothing is interrupted) and continue with a new single-thread executor. Calls without a timeout are unchanged. This stops the executor queue from turning one timeout into several; a classifier that serialises internally (for example, behind a lock the stuck call holds) would still lose the next documents.

} catch (TimeoutException _) {
  // add classification timeout
  time += 5000;
  // the timed-out classification still holds the only worker: leave it to finish on
  // its own (no interrupt, it may be reading the index) and give the next documents a
  // fresh worker, so that they do not wait behind it and time out as well
  executorService.shutdown();
  executorService = newExecutor();
}
...
private static ExecutorService newExecutor() {
  return Executors.newFixedThreadPool(1, new NamedThreadFactory("confusion-matrix-gen-"));
}

Trade-offs

  • Overlap. The stuck classification can now run at the same time as the next ones on the same Classifier instance; today the single worker serialises all calls. This is an open API-contract question: the Classifier interface says nothing about thread safety. The reader is safe to share (IndexReader instances "are completely thread safe", IndexReader.java:58-61), but a classifier's own state may not be. LUCENE-6045 (#7107) refactored the classifiers to be shared across threads, but some keep per-call mutable state: KNearestNeighborClassifier sets the boost settings of its shared MoreLikeThis inside each call (KNearestNeighborClassifier.java:160-172; read, no overlap failure was run). A javadoc note or a cap may be wanted.
  • Threads. One non-daemon thread per timed-out classification lives until that classification returns; one that never returns keeps its thread, as today; from the code (not run), such a non-daemon thread can keep the JVM from exiting normally, today and with the fix. A cap (stop after a few timeouts) would bound them.
  • Accounting unchanged. The 5,000 ms charge is still divided by the evaluated count (average 2,500 ms in the fixed 3-document runs), and the average is still Infinity when timeouts were charged but nothing was evaluated.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails. With the fix: it passes with assertions on (about 5.2 s instead of about 10 s) and off.
  • With the regression-test form: checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest and :lucene:classification:test pass: 44 tests (the module's 12 suites with 43 tests, plus the repro), 1 skipped (Test20NewsgroupsClassification, @Monster), 0 failures.
  • The follow-up's 3-document check with the fix shadowed over the 645d4fe14a20 jars: the hung and the 12 s cases both count 2 of 2 and return after 5,002 ms.

cancel(true) the timed-out task and replace the executor (not recommended)

A verifier's root-cause sketch: future.cancel(true), shutdownNow() and a new executor on a timeout, shutdownNow() in finally. It also makes the repro pass (6.4 s) and counts 2 documents in 5,080 ms when the first hangs.

Trade-offs

It interrupts a thread that is usually reading the caller's index; the NIOFSDirectory and MMapDirectory javadocs warn that this can close the channel for good (ClosedChannelException afterwards). The verifiers and the adjudication advise against proposing it.

Start each document's 5 s when its task starts running (not built)

Keep one worker, and measure the 5 s from the moment the task starts (or wait for the busy worker before submitting the next document).

Trade-offs

No overlap and no interrupt, but with no overall budget a classification that never returns would make the whole call wait forever (today it returns after 5 s per document); it needs a bound such as the overall budget.

Stop at the first timeout whose task is still running (not built)

break (or fail) instead of submitting more work to a busy worker.

Trade-offs

No overlap and no interrupt, and the call returns sooner, but no later document is evaluated, so the repro's expected count is not met; it makes the cut-off explicit only if documented.

Complement: document the 5 s limit and define the empty average (not built)

Javadoc the hard-coded 5 s per-document limit, and return a defined, documented value from getAvgClassificationTime() when no document was evaluated (today Infinity after timeouts, NaN when nothing added time).

Trade-offs

Accounting only; it does not stop the collateral timeouts.

Downstream note

No downstream caller was found: a read-only code search on 2026-10-27 found no use of ConfusionMatrixGenerator in Solr, Elasticsearch or OpenSearch, so only applications that evaluate their own classifiers with it are affected.

How it was found

Reading code while sweeping Lucene APIs that run work on executors: the generator creates its own one-thread executor, and its timeout branch never stops the timed-out task. A deterministic JUnit with a latch-blocked classification confirmed it on the stock jar.

The method in full123 words

Method: non-formal (code reading, then a public-API JUnit).

  • A hunt unit swept Lucene APIs that accept or create executors against differently behaving executors and tasks. ConfusionMatrixGenerator creates its own executor, so it was not one of the sweep's surfaces; reading its timeout path showed that a timed-out task is never cancelled and keeps the only worker.
  • A JUnit with one latch-blocked and two instant documents failed on the stock main jar with assertions on and off, and on 10.5.2 and 9.12.3.
  • Then an independent reproducer, a devil's-advocate verifier, cross-model reviews (OpenAI GPT-5.6, executed mode), an adjudication and a credibility audit checked it; a follow-up ran the version history, a prior-report search and a security screen; the repro was minimized to one 2-document test.

Prior reports, history and checks

Prior reports

No prior report found (searched on 2026-10-27 over GitHub, JIRA, the mailing lists and the web; GitHub rechecked on 2026-10-31). Closest: #7538, which added the timeout (commit notices and a thank-you comment only), and #9270 (slow classifier tests on CI; the timeout path was never discussed).

Searches3 searches

Verdict new: no issue, PR, commit message, list thread, JIRA entry or downstream report describes a timed-out classification blocking later documents, the Infinity average or queued classifications running after the call returns. No open PR touches the file, and upstream main 2f424fd428d (2026-10-30) has a file byte-identical to 645d4fe14a20's.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream), JIRA, mailing lists, web2026-10-27a scripted prior-report search in three query variants (51 queries each), 21 fresh GitHub searches (class, method, accessor and thread names, "confusion matrix" with timeout and thread), all module:classification items, downstream searches (Solr, Elasticsearch, OpenSearch), 8 JIRA queries, 9 list queries, 4 web searches incl. the exact comment text, the changed files of all 409 open PRsno report; related #7538 (origin), #8248 (overall budget), #9270 (slow CI tests), a 2019 dev@ thread on a JDK HashMap assertion, metric fixes #8211, #7912, #8359; SOLR-13903 is other code
Verifiers' own searches2026-10-27two scripted prior-report search runs (42 and 25 queries) and the finder's runno report
GitHub (recheck)2026-10-31ConfusionMatrixGenerator and "confusion matrix" in apache/lucene issues and PRs; classification issues and PRs created since 2026-10-26only the known items; nothing new

History

Introduced by LUCENE-6479 (8b2e0d937d5, 2015-09-02), which added the one-thread executor, the 5 s wait and the skip-without-cancel; first released in 6.0.0. Every release from 6.0.0 to 10.6.0 and main is affected; never fixed.

Versions tested8 rows

Introduced by 8b2e0d937d5 "LUCENE-6479 - improved cm testing, added stats, minor fixes" (Tommaso Teofili, @tteofili, 2015-09-02, svn r1700914; SVN era, no pull request; issue #7538). It added the one-thread pool, the comment, submit(...).get(5, SECONDS) with the Future dropped, time += 5000 without a cancel and finally shutdown() in one commit. Before it (2011dbedabd) classifications ran on the caller's thread with no timeout, so a hung one hung the call; the commit bounded the wait but did not isolate the stuck task. 9e340d3e82f (2015-09-03) only added the thread factory.

Symptom change, same defect: LUCENE-7193 (2507015f4c9, 2016-04-08, 6.1.0) added the overall timeoutMilliseconds and made the evaluated count count classified documents. In 6.0.x the count was the number of hits (3 in the hung case, with an empty matrix), so even the count hid the loss; from 6.1.0 it shows 0 and the average is Infinity.

All 83 release tags from 6.0.0 to 10.5.2 in the history clone contain the same code and no cancel( or shutdownNow; 8.8.2 to 8.11.3 are inferred from the executed 8.0.0 and 8.11.4. Release 10.6.0, tagged after the follow-up ran, has a file byte-identical to 10.5.2's; the fact check of this doc ran the minimal test and the 3-document check on it, and both fail.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off; 3-document check: 12 s gives 1 of 2, hung gives 0 of 2
10.6.0 affectedminimal test fails with assertions on and off; 3-document check: hung 0 of 2, 12 s 1 of 2 (fact check of this doc)
6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2; main a9779ef3c193 affected3-document check: hung 0 of 2 (empty matrix, average Infinity), 12 s 1 of 2; the JUnit tests fail on 9.12.3, 10.5.2 and main
6.0.0 affectedsame loss, but the evaluated count reports all 3 hits, so even the count hides it
8b2e0d937d5 (LUCENE-6479) committhe bug, shadowed over the 6.0.0 jars
parent 2011dbedabd commitno document dropped, but no timeout at all: a hung classification hangs the whole call; a 12 s one is counted with all others
5.5.5 not affectedno ConfusionMatrixGenerator class
with the recommended fix (645d4fe14a20) not affectedrepro passes; 3-document check: 2 of 2 in 5,002 ms for both cases

Independent checks

An independent reproducer, a devil's-advocate verifier, cross-model reviews (OpenAI GPT-5.6, executed), an adjudication and a credibility audit all reproduced it; all said real, the adjudication confirmed with high confidence, and the audit lowered the impact to minor. This doc's gradle run reproduced it and validated the fix.

Checks and verdicts366 words

Every check ran code on stock builds of main (a9779ef3c193, identical module) or releases; this doc re-ran on 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (JUnit) candidateOne latch-blocked and two instant documents: 0 of 2 counted on main with assertions on and off, 10.5.2 and 9.12.3.
Cross-model review, refute lens (OpenAI GPT-5.6) real, mediumIts own reader-backed JUnit: 0 documents evaluated when only the first blocks, assertions on and off; the all-instant control passes. Intended-behaviour argument "moderate".
Cross-model review, intended lens (OpenAI GPT-5.6) real, highOwn observation test: blocked case 0 evaluated, a non-daemon worker, queued calls run after release; the 12 s case drops the next instant document.
Independent reproducer realOwn 3-test JUnit (hung first; controls: all instant, hung last): fails on main, 10.5.2 and 9.12.3; controls pass; a cancel-and-replace sketch passes.
Devil's advocate real, medium (narrow)Measured the reach (7 s: none lost; 12 s: one; hung with a 10 s budget: returns at 10,004 ms with 0 evaluated) and narrowed the wording.
Adjudication confirmed, highRe-ran the independent reproduction's test (assertions on and off) and the intended-behaviour check's characterization; wrote the caveats.
Credibility audit downgrade to minorReproduced (finder's JUnit, 3-document check); lowered the impact class from silent wrong results to minor: no index, search or classifier answer is affected and the count shows the shortfall.
This doc (gradle, fix) reproduced, fix validatedRepro fails on 645d4fe14a20; the recommended fix passes it, the module's 43 tests (1 skipped, @Monster) and lint checks, and the 3-document check.
Fact check (2026-10-31) reproduced, wording fixesRe-ran the minimal test on 645d4fe14a20 and on release 10.6.0 (assertions on and off), the gradle command in a scratch worktree, the recommended fix with the module checks, and the 3-document check on main and 10.6.0; found 10.6.0 missing from the affected range. Its corrections were applied.
Cross-model fact check (OpenAI GPT-5.6) wording fixesRead-only review of the brief against the code, the history and the run logs: the bug holds; its corrections (NaN vs Infinity, which queued tasks are discarded, the budget, the reach-rule wording) were applied.

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

Finding record

LFV-633, snapshot of 2026-11-06

Registry title
ConfusionMatrixGenerator never cancels a classification that exceeded its 5 s limit, so later documents queue behind it on the single worker thread and are also counted as timed out
Area
classification / ConfusionMatrixGenerator (classifier evaluation utility)
Priority
Low, rank 814: In the opt-in classification evaluation utility, a stuck or slow classification blocks the single worker and skews the confusion matrix, which fits the executor stuck-task rows after the OfflineSorter hang LFV-384.
Impact class
minor
Impact note
Applications or tests that evaluate a Classifier with ConfusionMatrixGenerator.getConfusionMatrix (an opt-in evaluation utility in lucene/classification; in-tree only tests call it and no downstream caller was found) with timeoutMilliseconds <= 0 or above 5000 get a smaller, hit-order-biased confusion matrix with no error once one classification runs longer than about 10 s or never returns: documents whose own classification is instant and correct are counted as timed out because they waited behind it (finite 12 s classification: 1 of 2 later documents lost; hung classification: all later documents lost, getAvgClassificationTime Infinity), the shortfall shows only in getNumberOfEvaluatedDocs, and the stuck and queued classifications keep running on the generator's worker thread after the call returns; no index, search or stored data is touched; every release since 6.0.0 [exec].
Summary
You evaluate a classifier with ConfusionMatrixGenerator.getConfusionMatrix and one document's classification is slow (more than about 10 s) or hangs. You expect, as the code's own comment ('fail if classification takes more than 5s') and its skip-and-continue timeout branch intend, that only that document counts as a timeout and the other documents are still classified and counted. Instead the timed-out classification is never cancelled and keeps the generator's only worker thread, so the next documents wait behind it, their own 5 s waits expire while they are still queued, and they are dropped too: with a hung first document 0 of 2 instantly classifiable documents are counted (empty matrix, average time Infinity), with a 12 s one 1 of 2.
Introduced
8b2e0d937d58470edac86135c602ef70356c68b3 (LUCENE-6479 'improved cm testing, added stats, minor fixes', svn r1700914, by tteofili; SVN era, no pull request; issue migrated as apache/lucene#7538) [exec: commit-level A/B with the exact parent (2011dbedabd) and introducing sources, shadowed over the 6.0.0 release jars with the same two-line adaptation: the parent has no timeout at all (a hung classification hangs the call, a 12 s one is counted with all others), the introducing commit returns after about 15 s with an empty matrix for a hung first document and drops the instant document after a 12 s one; read: git blame -w on main attributes the one-thread pool, submit(...).get(5, SECONDS), time += 5000 and finally shutdown() to it, git log -L shows no later logic change]. Related later commits: 9e340d3e82f (2015-09-03, NamedThreadFactory 'confusion-matrix-gen-', no logic change); 2507015f4c9 on master / 646081c3dd5 on branch_6x (LUCENE-7193, 2016-04-08, 6.1.0: the aggregate timeoutMilliseconds and numberOfEvaluatedDocs counting classified documents; defect unchanged, symptom changes) (2015-09-02 (the LUCENE-7193 change that altered the symptom: 2016-04-08))
First affected version
6.0.0 [exec] (the first release with ConfusionMatrixGenerator; 5.5.5 has no such class [exec], so no release ever had the API without the defect)
Affected versions
every release with the API, 6.0.0 through 10.5.2, and main a9779ef3c193; never fixed. [exec] the version-independent main() check (all-instant control, hung, finite 12 s) on 6.0.0, 6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea; main also -da in the registration check), the finder's JUnit and the independent reproduction's JUnit on 9.12.3, 10.5.2 and main (-ea and -da on main), the GPT reviewer's own test (hung case) on 9.12.3. [read] all 83 release tags 6.0.0-10.5.2 in the history clone contain the one-thread pool, get(5, SECONDS), time += 5000 and shutdown() and no cancel( or shutdownNow; 8.8.2-8.11.3 (separate 8.x branch, not in the clone) are inferred from the executed 8.0.0 and 8.11.4. Symptom by version: 6.0.x reports getNumberOfEvaluatedDocs = totalHits (3) with an empty matrix and avg 5000, so even the count hides the loss; from 6.1.0 the count shows the shortfall (0) and the average is Infinity (time/0). Upstream main a95c754ee003 (2026-10-26) has a byte-identical file.
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: code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new): a scripted prior-report search in three query variants (51 queries each over GitHub issues, PRs and commits, downstream repositories, the lucene.apache.org and solr.apache.org lists and JIRA; 55, 99 and 151 distinct hits, none describing this bug), 21 fresh GitHub searches on apache/lucene (class, method, accessor and thread names, 'confusion matrix' with timeout and thread), all module:classification issues and PRs, GitHub-wide and downstream searches (Solr, Elasticsearch, OpenSearch), 8 JIRA queries across all projects (11 issues fetched), 9 list queries (18 messages read or scanned), plus the finder's run, the independent reproduction's 42-query run and the intended-behaviour check's 25-query run; the changed files of all 409 open apache/lucene PRs were scanned and none touches ConfusionMatrixGenerator.java or its test. Related, not this bug: #7538 (LUCENE-6479, the feature that added the 5 s wait; only commit notices), #8248 (LUCENE-7193, added the aggregate timeoutMilliseconds), #9270 (LUCENE-8223, classifier performance tests slow on CI; the fix removed the time-based assertions without examining the timeout path, and nothing links that slowness to this bug), a 2019 dev@ thread about a JDK HashMap assertion surfacing through getConfusionMatrix, and metric fixes #8211, #7912 and #8359; SOLR-13903 is Solr's own streaming confusion matrix, other code. Not fixed upstream: upstream main a95c754ee003 (2026-10-26) has a byte-identical file, its last change is 54283150a816 (#16255, a comment-spelling fix), and no commit ever added cancel( or shutdownNow under lucene/classification
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails through gradle in a scratch worktree (assertions on and off) and through a standalone runner; with the recommended fix it passes, together with the classification module's 43 tests (1 skipped, @Monster) and its format, ECJ and forbidden-apis checks, and the 3-document 12 s / hung check counts 2 of 2. The hunt, verifiers, judge and audit ran main a9779ef3c193, whose lucene/classification is identical; the follow-up ran the version-independent check on 11 releases from 6.0.0 to 10.5.2 (the other release tags carry the same code, by reading), and the fact check of this doc ran the minimal test and that check on 10.6.0: both fail.

Lucene and upstream sources

Finding record
  • lucene/classification/src/java/org/apache/lucene/classification/utils/ConfusionMatrixGenerator.java:41 (at a9779ef3c193) (class javadoc), :56 ('@param timeoutMilliseconds timeout to wait before stopping creating the confusion matrix', the only documented bound), :71-72 (Executors.newFixedThreadPool(1, new NamedThreadFactory("confusion-matrix-gen-"))), :87-89 (aggregate budget check, only when timeoutMilliseconds > 0), :100-105 ('// fail if classification takes more than 5s'; submit(() -> classifier.assignClass(text)).get(5, TimeUnit.SECONDS), the Future is not kept), :140-142 (catch TimeoutException: time += 5000, no cancel, next document), :149 (new ConfusionMatrix(counts, time / counter, counter): Infinity when counter is 0), :150-151 (finally executorService.shutdown()); the file has no diff between ea57360912ed and a9779ef3c193 (git diff)
  • lucene/classification/src/java/org/apache/lucene/classification/Classifier.java (at a9779ef3c193) (public interface, @lucene.experimental; no latency, cancellation or threading contract, per the intended-behaviour check); lucene/classification/src/java/org/apache/lucene/classification/utils/package-info.java ('Utilities for evaluation, data preparation, etc.'); lucene/core/src/java/org/apache/lucene/util/NamedThreadFactory.java:73 (setDaemon(false)); NIOFSDirectory and MMapDirectory class javadocs (Thread#interrupt and Future#cancel during index I/O can close the channel, per the intended-behaviour check)
  • https://github.com/apache/lucene/issues/7538 (LUCENE-6479, introducing issue), https://github.com/apache/lucene/issues/8248 (LUCENE-7193), https://github.com/apache/lucene/issues/9270 (LUCENE-8223)
The finding in 30 seconds
  • lucene/classification/src/java/org/apache/lucene/classification/utils/ConfusionMatrixGenerator.java:71-72, :87-89, :100-107, :140-142, :149-151 (main 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/NamedThreadFactory.java:73 (setDaemon(false))
Who is affected
  • lucene/classification/src/java/org/apache/lucene/classification/utils/package-info.java; lucene/classification/src/test (8 callers; 13 of 15 calls pass -1, Test20NewsgroupsClassification passes 60000 * 30)
  • https://github.com/apache/lucene/issues/9270 (LUCENE-8223, the test that was later relaxed; the issue itself has no timings)
Why it looks unintended
  • lucene/classification/src/java/org/apache/lucene/classification/utils/ConfusionMatrixGenerator.java:56, :100, :140-142, :337-344
  • lucene/core/src/java/org/apache/lucene/store/NIOFSDirectory.java:41-46; lucene/core/src/java/org/apache/lucene/store/MMapDirectory.java:73-78
Suggested fix
  • lucene/classification/src/java/org/apache/lucene/classification/KNearestNeighborClassifier.java:160-172; https://github.com/apache/lucene/issues/7107 (LUCENE-6045)
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:58-61 (IndexReader instances are completely thread safe)
Prior reports
  • https://github.com/apache/lucene/issues/7538, https://github.com/apache/lucene/issues/8248, https://github.com/apache/lucene/issues/9270

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 645d4fe14a20 and the repro's output.

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.