invokeAll waits for its tasks one future at a time. When the waiting thread is interrupted, Future.get() throws at once; the catch records the interrupt and moves to the next future, and never waits for the interrupted one again.
new IndexSearcher(reader, executor)splits the segments into slices and runs one task per slice throughTaskExecutor#invokeAll.invokeAllsubmitscount - 1workers to the executor, which take tasks one at a time, and the calling thread takes tasks too until none is left. Then it callsFuture.get()on each task in turn, so it returns only when every task is done.- If the calling thread is interrupted while it waits, the JDK's
Future.get()on a task that is still not done throwsInterruptedExceptionat once and clears the interrupt flag.
invokeAll runs task 0 on an executor thread and task 1 on the calling thread; the caller is interrupted, get() on task 0 throws at once, and invokeAll ends while task 0 is still running. Select the still to open it at full size. Generated with AI, checked against the code.- You search with
new IndexSearcher(reader, executor)and 2 slices: one slice runs on an executor thread, the other on your request thread, which then waits for the first one insideTaskExecutor#invokeAll. - While the executor's slice is still running, the request is cancelled with
Future.cancel(true), which interrupts the request thread. - You expect the search to throw
ThreadInterruptedExceptiononly after both slices have finished: the javadoc saysinvokeAllwill "wait for them to complete", and the code asserts that no task is left running. - Instead it ends at once. In the repro the executor's task waits on a latch for up to 10 s, yet
invokeAllends long before that bound (17-18 ms in a timing probe; with the fix it waits the full 10 s). With-ea:AssertionError: Some tasks are still running?; with-da:ThreadInterruptedException. - The abandoned slice keeps running: its exception is never reported to you, and if you close the reader while the slice still reads it, the slice fails with an
AlreadyClosedExceptionthat is never reported either.
Applications that run concurrent search (an IndexSearcher with an executor and at least 2 slices) and interrupt the searching thread, for example Future.cancel(true) for a request timeout or shutdownNow on the request pool. The default IndexSearcher without an executor is not affected. Rare: interrupt-based cancellation is unusual, and a Lucene committer said Lucene will not support it.
Mechanism, terms and numbers589 words
TaskExecutor#invokeAll runs some tasks on the executor and the rest on the calling thread, then waits for each future in turn. If the calling thread is interrupted while it waits, Future.get() throws InterruptedException; collectResults records it as a ThreadInterruptedException and moves on to the next future, but never waits for that one again. So invokeAll ends while the task is still running, against its javadoc ("wait for them to complete"), a code comment ("invokeAll does not leave any running tasks behind when it returns") and its own assertion, which fires with -ea. With -da the call throws ThreadInterruptedException; through IndexSearcher#search a slice keeps collecting after search has thrown, its exception is lost, and if the caller closes the reader while the slice still reads it, the slice fails with an AlreadyClosedException that is never reported. It needs concurrent search with an executor and an application that interrupts the searching thread. Present in this form since 9.9.0, when #12523 made invokeAll wait for all tasks on failure but left the interrupt path behind; every release through 10.6.0 and main.
// invokeAll: count - 1 workers go to the executor, the caller runs tasks until none is left (:91-114)
return collectResults(futures); // :115
for (Future<T> future : futures) { // collectResults, :121
try {
results.add(future.get()); // :123
} catch (InterruptedException e) {
exc = IOUtils.useOrSuppress(exc, new ThreadInterruptedException(e)); // :124-125, then the next future
} catch (ExecutionException e) {
exc = IOUtils.useOrSuppress(exc, e.getCause()); // :126-127
}
}
assert assertAllFuturesCompleted(futures) : "Some tasks are still running?"; // :130
if (exc != null) { throw IOUtils.rethrowAlways(exc); } // :131-133- Every task is claimed before the wait. The caller keeps claiming task ids until none is left (
:107-114), so incollectResultsa future that is not done has been claimed by another thread, which is running it or about to start it. - Only the interrupted future is abandoned (one per interrupt).
FutureTask.get()on a task that is still not done checks and clears the interrupt flag and throws. The loop records it and callsget()on the next future, which now blocks normally. An independent test with 3 tasks shows it: after the interruptinvokeAllkept waiting while both pool tasks were blocked, and returned as soon as task 2 was released, with task 0 still blocked ("done at return: [false, true, true]"). A second interrupt during a later wait would abandon a second future. - Outcome.
-ea: the assertion at:130fails.-da::131-133rethrowThreadInterruptedException. The abandoned task's value or exception is never read. - Through
IndexSearcher.search(Query, CollectorManager)runs one task per slice throughtaskExecutor.invokeAll(listTasks)(IndexSearcher.java:778-789). The throw skipsreduce; the abandoned slice keeps collecting and reading the reader. - Not without an executor.
new IndexSearcher(reader)usesnew TaskExecutor(Runnable::run)(IndexSearcher.java:223): every task runs on the calling thread before the wait, so nothing is left running.
| main | with the fix | |
|---|---|---|
minimal repro: when invokeAll ends | at once (17-18 ms in a timing probe), task 0 still running | after task 0 completes (its 10 s bound; 10.2 s through gradle) |
what it throws, -ea | AssertionError: Some tasks are still running? | ThreadInterruptedException |
what it throws, -da | ThreadInterruptedException | ThreadInterruptedException |
| a later exception of task 0 | never reported | attached as suppressed |
IndexSearcher#search, 2 slices, Future.cancel(true) on the request | throws while the pool slice runs; the test then closes the reader and lets the slice read it: AlreadyClosedException, unreported | ends after the slice completed; no AlreadyClosedException |
Exposure and likelihood251 words
- Who.
TaskExecutorwith a real executor, in practicenew IndexSearcher(reader, executor)(opt-in,@lucene.experimental) with 2 or more slices, plus an application that interrupts the thread that calledsearch. OtherinvokeAllcallers, by reading only: kNN query rewrite (AbstractKnnVectorQuery), match highlighting (MatchRegionRetriever), the sandboxPostCollectionFaceting, the concurrent HNSW merge (HnswConcurrentMergeBuilder), and BP index reordering inlucene/misc(BPIndexReorderer). - With assertions on (test suites, CI): an
AssertionErrorfrom inside Lucene instead of theThreadInterruptedExceptionthe code means to throw. - With assertions off (production): the call throws while a slice still runs. That slice's exception is never reported to the caller, the slice may keep running (and using CPU, read from the code, not measured) after the request was cancelled, and if the caller closes the reader while the slice still reads it, the slice fails with an
AlreadyClosedExceptionthat is never reported (observed withByteBuffersDirectory;MMapDirectoryandNIOFSDirectorynot tested). No wrong result is returned, because the call throws. - Scale. Any index with 2 or more slices and one interrupt that reaches
get()while that task is still not done; it reproduced in every controlled iteration (20 of 20 and 10 of 10). - Likelihood: rare. A Lucene committer said Lucene will not support interrupting long-running queries ("not going to support Thread.interrupt or any nonsense like that. you already have the exitable reader: use that", #12023), and the
IndexSearcherjavadoc warns thatshutdownNowinterrupts can closeNIOFSDirectoryfile descriptors. No report was found.
One JUnit class, one method, public API only, no index. Two tasks: the executor's task waits on a latch (up to 10 s), the caller's task interrupts the calling thread. The test checks that invokeAll ended only after the executor's task finished. It fails with assertions on and off.
/*
* 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.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTaskExecutorInterruptedCaller extends LuceneTestCase {
public void testInvokeAllWaitsForRunningTaskWhenCallerIsInterrupted() throws Exception {
Semaphore started = new Semaphore(0);
CountDownLatch release = new CountDownLatch(1);
AtomicBoolean task0Done = new AtomicBoolean();
Executor executor = r -> { new Thread(r).start(); started.acquireUninterruptibly(); };
Callable<Void> task0 = () -> {
started.release();
release.await(10, TimeUnit.SECONDS);
task0Done.set(true);
return null;
};
Callable<Void> task1 = () -> { Thread.currentThread().interrupt(); return null; };
Throwable thrown = null;
try {
new TaskExecutor(executor).invokeAll(List.of(task0, task1));
} catch (Throwable t) {
thrown = t;
}
boolean task0DoneAtReturn = task0Done.get();
release.countDown();
// expected: invokeAll ends only after task 0 has completed; actual: it ends while task 0 still runs
assertTrue("expected invokeAll to wait for task 0, but it ended with " + thrown + " while task 0 was still running", task0DoneAtReturn);
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTaskExecutorInterruptedCaller -Ptests.seed=DEADBEEF
- Expected
invokeAllends, normally or withThreadInterruptedException, only after task 0 has completed.- Observed
- It ends at once while task 0 still runs:
AssertionError: Some tasks are still running?with-ea,ThreadInterruptedExceptionwith-da.
More files, environment, full output331 words
The class sits in org.apache.lucene.search next to TestTaskExecutor and uses only TaskExecutor(Executor) and invokeAll. Concurrency is the bug, so it needs one executor thread, but the order is fixed by a Semaphore and a latch, without sleeps: the executor starts a new thread per execute() and returns only once that thread's task has started, so the executor thread always runs task 0 and the calling thread runs task 1, the last one. Task 1 sets the calling thread's interrupt flag, standing for a cancellation interrupt that lands while the caller runs its own share; the independent test shows the same with a real Future.cancel(true) from another thread. Task 0's 10 s bound only lets a correct invokeAll, which keeps waiting for it, end.
The test applies to 9.12.0 and later, where the caller runs the last task itself; before 9.12.0 a port was used (run on 9.8.0, 9.9.0 and 9.11.1).
Regression test: this same class (or its one method in TestTaskExecutor); with the recommended fix it passes in about 10 s, the length of task 0's bound.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 9.12.3, 10.5.2, 10.6.0: fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
cp $REPRO/TestTaskExecutorInterruptedCaller.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTaskExecutorInterruptedCaller -Ptests.seed=DEADBEEF
# assertions off fails too, with the other message: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (stack trimmed)
TestTaskExecutorInterruptedCaller > testInvokeAllWaitsForRunningTaskWhenCallerIsInterrupted FAILED
java.lang.AssertionError: expected invokeAll to wait for task 0, but it ended with java.lang.AssertionError: Some tasks are still running? while task 0 was still running
at __randomizedtesting.SeedInfo.seed([DEADBEEF:B96B8E1E2B8E23BD]:0)
at org.apache.lucene.search.TestTaskExecutorInterruptedCaller.testInvokeAllWaitsForRunningTaskWhenCallerIsInterrupted(TestTaskExecutorInterruptedCaller.java:51)
:lucene:core:test (FAILURE): 1 test, 1 failure
# the same with -Ptests.asserts=false
java.lang.AssertionError: expected invokeAll to wait for task 0, but it ended with org.apache.lucene.util.ThreadInterruptedException: java.lang.InterruptedException while task 0 was still running
# standalone runner: main fails with -ea and -da (20 of 20 derived seeds with -da); 10.5.2 (-ea, -da), 10.6.0 and 9.12.3 (-ea) fail the same way- The javadoc says
invokeAllwill "wait for them to complete", a comment says it "does not leave any running tasks behind", and it asserts exactly that. - Since #12523
invokeAllwaits for all tasks when a task fails; only the interrupt path still skips one. - Not a return-at-once policy either: after the interrupt it keeps waiting for the other tasks.
The full argument251 words
- The class states the opposite. Class javadoc: "then waits for all tasks that have been executed in parallel on the executor to be completed and then returns" (
TaskExecutor.java:41-43).invokeAll: "wait for them to complete and return the obtained results" (:71).Task#cancelcomment: "We rather want to make sure that invokeAll does not leave any running tasks behind when it returns." (:185-186). The assertion:assertAllFuturesCompleted(futures) : "Some tasks are still running?"(:130). - History. #12523 (2023, first in 9.9.0): "This commit ensures that it instead waits for all tasks to complete before it re-throws the exception." It turned
throw new ThreadInterruptedException(e)into record-and-continue, without waiting for the interrupted future again. #12689 (2023, 9.9.0) added the assertion and the comment. None of the PR threads that shaped the class (#12523, #12689, #13472, #13609, #13622, #13861) mentions interrupts. - The expectation upstream. #12278 (open): "Is it a reasonable expectation that once
IndexSearcher#search(Query, CollectorManager)returns, that specific search has completed and has left no threads behind still working on it? I would expect this to be true". It is about task exceptions, not interrupts. - Tests.
TestTaskExecutorhas 10 tests; none mentions interrupts. - Not a coherent alternative. After the interrupt the loop still waits for every other running task and abandons only the one it was blocked on (executed).
- A legal, anticipated trigger. Lucene's javadoc addresses "Applications using
Thread#interrupt()orFuture#cancel(boolean)" (FSDirectory.java:65-67,:77-82;NIOFSDirectory.java:41-46), andIndexWriterdocuments its own interrupt handling (IndexWriter.java:172-175).
The devil's-advocate check207 words
The strongest case for "intended" (a read-only cross-model review and the tie-break): Lucene does not support Thread#interrupt as cancellation (#12023), TaskExecutor is @lucene.experimental, and the JDK's own ExecutorService#invokeAll returns at once when interrupted, so an interrupted caller cannot expect the wait-for-all guarantee.
Why it fails: #12023 rejects making Lucene stop its work on interrupt; this report asks only that the existing interrupt handling keeps the invariant the class states, comments and asserts. @lucene.experimental marks API instability, not a licence to break a stated invariant. The JDK cancels the remaining tasks with cancel(true) before it returns (cooperatively, so a task that ignores interrupts can outlive its invokeAll too, but every task is asked to stop); Lucene's Task#cancel rejects that (assert mayInterruptIfRunning == false : "cancelling tasks that are running is not supported", :183), and today's code does wait for the other tasks, so it follows neither policy. The reviewers' one request, a genuine interrupt from another thread with standard pools and a control, was executed and fails the same way. Still, whether invokeAll should wait for all tasks or return at once after an interrupt is a policy choice for maintainers, not an established Lucene-wide guarantee; the report is that today's code follows neither.
Recommended After an interrupt, keep waiting for the same future and report the interrupt at the end (validated)
In collectResults, when Future.get() throws InterruptedException, remember the interrupt once and call get() again on the same task; throw ThreadInterruptedException after every task is done (+16/-6 in TaskExecutor.java). The repro, TestTaskExecutor and TestIndexSearcher pass. Trade-off: an interrupted caller now waits for its running tasks.
while (true) {
try {
results.add(future.get());
break;
} catch (InterruptedException e) {
if (interrupted == false) { // report the interrupt once, keep waiting
interrupted = true;
exc = IOUtils.useOrSuppress(exc, new ThreadInterruptedException(e));
}
} catch (ExecutionException e) {
exc = IOUtils.useOrSuppress(exc, e.getCause());
break;
}
}All options and trade-offs2 options · 444 words
One fix was validated through gradle in a scratch worktree of 645d4fe14a20. The alternative, returning at once and documenting it, is a policy choice for maintainers; it was not run.
After an interrupt, keep waiting for the same future and report the interrupt at the end (validated) (recommended)
Wrap the get() in a loop: on InterruptedException record one ThreadInterruptedException and retry; on a result or an ExecutionException leave the loop.
while (true) {
try {
results.add(future.get());
break;
} catch (InterruptedException e) {
if (interrupted == false) { // report the interrupt once, keep waiting
interrupted = true;
exc = IOUtils.useOrSuppress(exc, new ThreadInterruptedException(e));
}
} catch (ExecutionException e) {
exc = IOUtils.useOrSuppress(exc, e.getCause());
break;
}
}Trade-offs
- An interrupt no longer frees the calling thread before its running tasks end. It waits as long as it already waits for the other tasks today, and as after a task failure. Cancellation stays with the supported mechanisms (query timeouts,
ExitableDirectoryReader). - The interrupt flag stays cleared, as today and as
IndexWriterdocuments for its own interrupts; restoring it before the throw is a separate choice (not run). Further interrupts during the same wait are not recorded separately, and the flag is not restored for them. - Cancelling not-yet-started tasks would not help: when
collectResultsruns every task is already claimed, andcancel(false)cannot stop a task that is running (at most it would skip one claimed in the instant before itsrun()starts).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31): stock, the minimal test fails with assertions on and off. With the fix: the minimal test passes (10.2 s),TestTaskExecutor10/10 andTestIndexSearcher10 passed, 1 skipped by an assumption, 0 failures; the minimal test also passes with assertions off;TestTaskExecutorwith seed 5EEDCAFE and 3 iterations: 30 tests, 0 failures. Formatting was not checked. - Earlier checks: the study's 5 tests pass (the interrupt is still reported; task 0's late exception is attached as suppressed), the independent tie-break's 3 tests pass with assertions on and off, and the TLA+ model with the fix is clean (73 distinct states).
On InterruptedException, stop waiting and throw; change the javadoc and the cancel comment to say that running tasks may outlive the call, and make the assertion skip the interrupted case.
Trade-offs
Frees the interrupted caller at once, like the JDK's invokeAll, but brings back what #12523 removed for task failures: tasks that outlive the call, lost exceptions, and slices that can touch a reader the caller closes next. Running tasks cannot be interrupted either, because Task#cancel rejects mayInterruptIfRunning.
Downstream, this matters to a search service that cancels requests by interrupting threads that run concurrent search: the cancelled request returns while a slice still runs on the shared pool, and that slice's failure is never reported.
TLA+ model checking (TLC) of TaskExecutor#invokeAll, with an interrupt of the calling thread as one modelled event. TLC found a 10-state trace in which invokeAll returns while a task is still running (invariant NoRunningAfterReturn). An independent verifier reproduced it in Java; a one-test repro and a tie-break test with standard pools followed.
The method in full161 words
Method: formal (TLA+ model checking, the tool's own counterexample, then public-API JUnit tests).
- Model.
TaskExec.tlamodelsinvokeAllwith nesting, rejecting executors, exceptions, cancellation and interrupts of the application thread (the JDK'sFutureTask.get()throws only if the task is not done and the flag is set). - Counterexamples. Configuration
te_intr: the worker runs task 1, the caller runs task 2, the caller is interrupted while it waits on future 1, andinvokeAllcompletes while task 1 is still running: invariantNoRunningAfterReturnviolated, 10-state trace.te_intr_exc_lost: the task then throws, and the exception is never reported (NoLostException). With the fix (keep waiting after an interrupt) every configuration is clean. - Confirmation. A separate verifier wrote a 5-test class (4 fail, the control passes, on 10.1.0, 10.5.2 and main). The repro was reduced to one test; a later tie-break wrote an independent test with standard JDK pools and a real
Future.cancel(true), which fails 10 of 10 on main, 10.5.2 and 10.6.0.
No prior report found (183 logged queries over GitHub, the JIRA archive, the mailing lists, downstream projects and the web, 2026-10-31, repeating the study's searches). Closest: open #12278, which states the "no threads left behind" expectation for task exceptions, and #12523, which fixed that case.
Searches2 searches
Verdict new. Related: #12278 (the expectation), #12523 (the change that left the interrupt path behind) and #12689 (the assertion). Open PR #16778 would make collectResults public with the same interrupt handling. Policy context: #12023 (interrupts as cancellation) and #5714 / LUCENE-4649 (throwing ThreadInterruptedException clears the flag, a separate point).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), the JIRA archive, dev@, java-user@, issues@ and commits@, downstream repositories, the web | 2026-10-31 (study searches 2026-10-22/23) | a scripted prior-report search (85 queries) and own GitHub searches (76 queries), including the exact assertion text, assertAllFuturesCompleted, TaskExecutor ThreadInterruptedException and collectResults InterruptedException; 55 items read in full | no report; precursors #12278, #12523, #12689 |
| Every PR behind the 20 commits on TaskExecutor.java; all 410 open PRs | 2026-10-31 | bodies, comments and review comments read; open PRs listed with their files | no interrupt discussion; four open PRs touch the file (#16778, #15208, #14524, #13860) and none changes the interrupt branch |
In this form since 9.9.0: #12523 made invokeAll wait for all tasks on failure and left the interrupt path behind, and #12689 added the assertion. Every release 9.9.0 to 10.6.0 and main fails. 9.8.0 and the earlier IndexSearcher threw at once on interrupt, when every failure still returned early.
Versions tested7 rows
In the reported form, since 9.9.0. #12523 (73714934787 on main, backport 50f9a6731c2, 2023-10-05; first release 9.9.0) made invokeAll wait for all tasks before rethrowing and turned the interrupt branch into record-and-continue without a second get(); #12689 (1200ecce3a2, 2023-10-24) added the assertion and the cancel comment. #13861 (7757d43f4c9, 2024) moved the loop into collectResults; nothing since has touched it, and upstream main 2f424fd428d has the same file.
Older, the same early return without the invariant. 9.8.0, where TaskExecutor was created by #12398 (f44cc45cf8b), threw ThreadInterruptedException at once, as did IndexSearcher's concurrent search since LUCENE-2837 (4.0.0). Then every task failure returned early too, which #12278 reported and #12523 fixed. The registry's pin (#12523, 9.9.0) is the start of the broken invariant.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with -ea and -da (gradle and the standalone runner); the tie-break test fails 10 of 10 |
| 10.6.0, 10.5.2 | affected | minimal repro fails (10.5.2 with -ea and -da, 10.6.0 with -ea, and with -da in the fact check); the tie-break test fails (-da) |
| 9.12.0, 9.12.3, 10.0.0, 10.1.0 | affected | minimal repro fails (10.1.0: the study's test) |
| 9.9.0, 9.11.1 | port | 9.9.0: -ea gives the assertion, -da ThreadInterruptedException; 9.11.1: -ea gives the assertion (-da not run). The caller does not run the last task before 9.12.0, so a port is used; 9.9.1, 9.9.2, 9.10.0 and 9.11.0 were read, not run |
| 9.8.0 | before the invariant | throws ThreadInterruptedException at once while task 0 runs (port); then every task failure returned early too |
| 9.7.0 (and 4.0.0+ by reading) | IndexSearcher, before TaskExecutor | concurrent search throws ThreadInterruptedException while a slice runs; the no-interrupt control passes |
| the recommended fix on main | not affected | minimal repro, TestTaskExecutor and TestIndexSearcher pass; the tie-break and study tests pass |
The study's verifier, a re-verification and an independent tie-break test (standard JDK pools, a real Future.cancel(true), 10 of 10) reproduced it on main and releases; a read-only cross-model review gave no verdict on the merits. This doc's gradle run reproduced it and validated the fix.
Checks and verdicts196 words
Every executed check ran on stock builds; the cross-model reviewer could only read code.
| Check | Verdict | What it found |
|---|---|---|
| Study model (TLA+, TLC) | counterexample | te_intr: NoRunningAfterReturn violated (10 states); te_intr_exc_lost: NoLostException violated; fix configurations clean. |
| Study verifier | new bug | Own 5-test class: early return, lost exception, asynchronous interrupt and IndexSearcher with AlreadyClosedException fail; the control passes. |
| Re-verification | real | Minimal test on main with -ea and -da (20 of 20), 10.5.2, 10.6.0, 9.12.3; the fix passes. |
| Cross-model review (OpenAI GPT-5.6, refute lens) | no verdict | Two attempts failed to start and one could only read the code; every 'not real' is labelled procedural; nothing was executed. |
| Second adjudication | real | Independent test with standard JDK pools and Future.cancel(true): the cancelled search and the selective test fail 10 of 10, the control passes; 10.5.2 and 10.6.0 fail; the fix passes 3/3. |
| Follow-up (history, prior art) | since 9.9.0 in this form; new | 9.7.0 to main executed; 183-query prior-art search. |
| This doc's runs | reproduced, fix validated | Gradle on 645d4fe14a20: the minimal test fails with -ea and -da; with the fix it, TestTaskExecutor and TestIndexSearcher pass. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 457 KB
Finding record, Lucene and upstream sources, repro files and diagram notes593 words
- Registry title
- TaskExecutor#invokeAll returns while a task is still running if the caller is interrupted
- Area
- search / concurrency
- Priority
- Low, rank 831: An interrupted caller of concurrent search returns while a task still runs, losing its exception and letting it touch a released reader (AlreadyClosedException, no memory unsafety), but Lucene does not support interrupt-based cancellation.
- Impact class
- wrong error
- Impact note
- Applications that run concurrent search (IndexSearcher with an executor) and interrupt the calling thread (Future.cancel(true), shutdownNow); released 9.9.0-10.5.2.
- Summary
- You run a concurrent search and the calling thread is interrupted. You expect invokeAll to wait for its tasks or to propagate their failures. Instead it returns while a task still runs: with assertions on an AssertionError fires, and with assertions off the task's exception is lost and the task may keep reading a reader the caller has released.
- Introduced
- #12523 (73714934787) made invokeAll record an interrupt and continue without retrying get() [read] (2023-10-05)
- First affected version
- 9.9.0 [read]
- Affected versions
- 9.9.0-10.5.2 [read]; 10.1.0, 10.5.2 and main [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
- Found by
- formal: TLA+/TLC
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree with assertions on and off, and passes with the recommended fix, as doTestTaskExecutorandTestIndexSearcher. The standalone runner fails it on main (20 of 20 seeds), 10.5.2, 10.6.0 and 9.12.3; 9.8.0, 9.9.0 and 9.11.1 fail a port of it (the test itself needs 9.12.0+). The fact check also ran it on 10.6.0 with assertions off: it fails. An independent test with standard JDK pools and a realFuture.cancel(true)fails on main, 10.5.2 and 10.6.0. Upstream main2f424fd428d(2026-10-30) has the sameTaskExecutor.java(read from the source).
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:91-115 (fork, caller loop, collectResults call), :118-135 (collectResults; :123 get, :124-125 record and continue, :130 assertion, :131-133 rethrow)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:222-223 (TaskExecutor(Runnable::run) without an executor), :778-789 (one task per slice, invokeAll, reduce)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:194-199, :207-212 (shutdownNow warnings; @lucene.experimental)a Lucene main checkout (645d4fe14a20) git grep 'invokeAll(' over lucene/*/src/java: AbstractKnnVectorQuery.java:221, MatchRegionRetriever.java:306, PostCollectionFaceting.java:263, HnswConcurrentMergeBuilder.java:215, :254, :391, :437, lucene/misc BPIndexReorderer.java:186 (a TaskExecutor, :160, :800); not TaskExecutor calls: DrillSideways.java:508, PartitionMatcher.java:90 (ExecutorService), BpVectorReorderer (ForkJoinTask)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:41-43, :45 (@lucene.experimental), :71, :183, :185-186, :130a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestTaskExecutor.java (10 tests; no 'interrupt')a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:65-67, :77-82; store/NIOFSDirectory.java:41-46; index/IndexWriter.java:172-175
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:107-114 (every task claimed before collectResults), :94-96 and :170 (claim, then start), :183 (cancel rejects mayInterruptIfRunning), :191-195 (cancel completes only a task not yet started)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:172-175 (interrupt status cleared)
- TestTaskExecutorInterruptedCaller.java (one test class):
repro/LFV-056/src/org/apache/lucene/search/TestTaskExecutorInterruptedCaller.java(53 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro.
assets/LFV-056/bug.png(457 KB)
