OfflineSorter caps the partitions it holds in memory with a pool of maxPartitionsInRAM permits that belongs to the sorter object. A partition whose sort task the executor rejects keeps its permit forever.
OfflineSortersorts more data than fits in RAM: it reads the input in partitions of at most the buffer size, sorts each partition into a temp file, then merges the files.- With the 8-argument constructor you can pass your own
ExecutorService: partitions are then sorted on its threads, andmaxPartitionsInRAMcaps how many are held in memory at once. - The cap is a semaphore created once per sorter. Reading a partition takes a permit; the task that sorts it gives the permit back when it finishes, also on error.
maxPartitionsInRAM = 1 the next sort() waits for it until interrupted. Select the still to open it at full size. Generated with AI, checked against the code.- Build an
OfflineSorterwith your own executor, here a single-thread executor that is already shut down, andmaxPartitionsInRAM = 1. Sort a one-record file:sort()throwsRejectedExecutionException, as it should. - Call
sort()again on the same sorter. You expect the same exception at once (with the fix it does: the test method takes 0.07 s). - But the first call had taken the sorter's only permit for its one partition, and only the sort task gives a permit back, when it finishes. The executor refused that task, so it never ran and the permit never came back.
- The second call is still blocked after 10 s, parked waiting for that permit with an idle executor, until something interrupts it. With
maxPartitionsInRAM = 2, the first rejection silently halves concurrency and the second makes every later call wait.
Code that passes its own executor to the 8-argument constructor, lets it reject work (shut down, or bounded and full with the default AbortPolicy) and then calls sort() again on the same sorter, for example as a retry. The default constructors and every in-tree caller are not affected; the class is internal and experimental. Rare.
Mechanism, terms and numbers496 words
OfflineSorter, Lucene's external sorter in org.apache.lucene.util, can sort partitions concurrently on an ExecutorService you pass in, holding at most maxPartitionsInRAM partitions in memory. It enforces that cap with a semaphore owned by the sorter: readPartition takes a permit for each partition, and only the finally of the task that sorts the partition gives it back. sort() submits that task with no guard, so when the executor rejects it (shut down, or bounded and full), sort() throws RejectedExecutionException and the permit is lost for good. Each rejection lowers the sorter's concurrency by one; once all permits are gone, every later sort() on that instance parks in Semaphore.acquire until interrupted, even with an idle executor, and a shut-down executor's fail-fast exception becomes an indefinite wait. Results are never wrong and nothing is written to the index. The default constructors cannot hit it and no in-tree caller passes an executor. Every release from 6.6.0 to 10.6.0 and main is affected.
- A permit per partition:
readPartitionstarts withpartitionsInRAM.acquire()(OfflineSorter.java:430-433) and, on success, returns the partition still holding the permit (:478). - Who gives it back. Three places, and only one for a partition with data: the empty last read in
sort()(:301-304),readPartition's own failure path (:479-484), and thefinallyofSortPartitionTask.call(:652-656). - The unguarded hand-off (
:309-311):
Callable<Partition> job = new SortPartitionTask(trackingDir, part);
segments.add(exec.submit(job));- If
submitthrows,call()never runs, so itsfinallynever runs. Thecatch (Throwable t)ofsort()(:366-369) deletes the temp files and rethrows; it does not touch the semaphore. - The loss outlives the call:
partitionsInRAMis aprivate final Semaphore(:76) created once per instance (:250).sort()resets its per-call state (sortInfo = new SortInfo(),:289); there is noclose()and no "already used" check. The next call opens its input (:298) and parks inacquire(:432), holding that input open. - Not affected: the merge hand-off (
:398) holds no permit. The 2- and 3-argument constructors passnull, so the sorter creates its ownSameThreadExecutorServicewith one permit (:246-249); nothing outside the sorter can reach that executor, and it rejects only aftershutdown().
maxPartitionsInRAM = 1, one-record input, executor already shut down: the firstsort()throwsRejectedExecutionException; the second is still blocked after 10 s, inSemaphore.acquire<-OfflineSorter.readPartition(OfflineSorter.java:432)<-OfflineSorter.sort(OfflineSorter.java:300).- A bounded pool instead (1 thread, queue of 16, default
AbortPolicy, filled by other work): the retry after the pool has drained waits withexecutor active=0 queued=0. maxPartitionsInRAM = 2: after one rejection the next sort still returns correctly sorted output (one permit left); after a second rejection the next sort waits.- An interrupt ends the wait:
sort()throwsThreadInterruptedException(:363-365). A new sorter on the same executor fails fast; only the old instance is broken.
Exposure and likelihood270 words
- Who. Code using the 8-argument constructor with an
ExecutorServicethat can reject: one that has been shut down, or a boundedThreadPoolExecutorwith the defaultAbortPolicythat other work has filled. It then has to callsort()again on the sameOfflineSorterafter theRejectedExecutionException. - Effect. Each rejected partition task silently removes one of the
maxPartitionsInRAMpermits. Before the last is gone, sorting is only less concurrent and stays correct. After that, every latersort()on the instance parks without CPU use, holding one thread and one open input file, until interrupted (a probe: 8 parked calls used 0 ms of CPU over 2 s and held 8 open inputs; interrupting them closed all 8 and left no temp files). With a shut-down executor, a call that should fail fast waits instead. - Not hit. The 2- and 3-argument constructors (their private same-thread executor is never shut down). No in-tree caller passes an executor: the hunspell
SortingStrategy,FSTCompletionLookup,SortedInputIteratorandAnalyzingSuggesteruse the default constructors, and BKDWriter passednull, 0back in 6.x and 7.x. A read-only, non-exhaustive code search found no downstream use of the executor constructor. One in-tree public API does take a sorter the application built:ExternalRefSorter(lucene/suggest, also@lucene.internal) callssort()fromiterator(), so it can be hit only if the application hands it an executor-backed sorter and reuses that sorter after a rejection. - Scale. Any input size: the repro sorts one record. The class is tagged
@lucene.experimentaland@lucene.internal. - Likelihood: rare. It needs a rejecting executor plus reuse of the failed sorter; nobody has reported it since 2017.
One JUnit class, one method, public API only: a one-record file, maxPartitionsInRAM = 1 and an executor that is already shut down. The first sort() must throw RejectedExecutionException; the second runs on a watchdog thread and must do the same within 10 s. 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.util;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.RejectedExecutionException;
import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestOfflineSorterRejectedSubmit extends LuceneTestCase {
public void testSortAfterRejectedSubmit() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexOutput out = dir.createOutput("input", IOContext.DEFAULT)) {
new OfflineSorter.ByteSequencesWriter(out).write(new byte[] {42});
CodecUtil.writeFooter(out);
}
ExecutorService exec = Executors.newSingleThreadExecutor(new NamedThreadFactory("sorter"));
exec.shutdown();
OfflineSorter sorter =
new OfflineSorter(
dir,
"tmp",
OfflineSorter.DEFAULT_COMPARATOR,
OfflineSorter.BufferSize.megabytes(1),
OfflineSorter.MAX_TEMPFILES,
-1,
exec,
1);
expectThrows(RejectedExecutionException.class, () -> sorter.sort("input"));
FutureTask<String> secondSort = new FutureTask<>(() -> sorter.sort("input"));
Thread t = new Thread(secondSort);
t.start();
t.join(10_000);
boolean blocked = t.isAlive();
t.interrupt();
t.join();
// expected: the second sort() is rejected again; actual: it waits for the permit the first one lost
assertFalse("second sort() still blocked after 10 s, expected RejectedExecutionException", blocked);
assertTrue(
expectThrows(ExecutionException.class, secondSort::get).getCause()
instanceof RejectedExecutionException);
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestOfflineSorterRejectedSubmit -Ptests.seed=DEADBEEF
- Expected
- The second
sort()throwsRejectedExecutionExceptionat once, like the first (with the fix the test method takes 0.07 s). - Observed
second sort() still blocked after 10 s, expected RejectedExecutionException: the call is parked inSemaphore.acquireinsideOfflineSorter.readPartition.
More files, environment, full output380 words
The class sits in org.apache.lucene.util so it can live next to TestOfflineSorter; it uses no package-private member (an independent test in another package reproduced it through the public API alone). Smallest input: one record (the byte 42), one partition, ByteBuffersDirectory, a 1 MB buffer, maxPartitionsInRAM = 1, and a single-thread executor shut down before use, so it never starts a thread (NamedThreadFactory is there only because forbidden-apis bans the factory-less Executors.newSingleThreadExecutor()). The second sort() runs on its own thread only because a hang cannot be bounded on the calling thread: after up to 10 s the test records whether it is still running, interrupts it (which ends the wait, so no thread leaks), and fails if it was blocked or ended with anything but RejectedExecutionException.
./gradlew tidy re-wraps two lines of this file that are over the column limit; the copy that ran through gradle has the same code, with that comment shortened instead of wrapped.
Regression test: this same class (or its one method added to TestOfflineSorter); with the recommended fix it passes at once (0.07 s for the method in a gradle re-run). It pins the fail-fast behaviour of the recommended fix; with the run-on-caller alternative the first sort() would succeed, so the test would assert success twice instead.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; release 10.6.0: fails; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; a semaphore count that never comes back) |
cp $REPRO/TestOfflineSorterRejectedSubmit.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestOfflineSorterRejectedSubmit -Ptests.seed=DEADBEEF
# fails after about 10 s; assertions off fails the same way: -Ptests.asserts=false (the recorded runs added --offline)
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (stack trimmed); the same with -Ptests.asserts=false
TestOfflineSorterRejectedSubmit > testSortAfterRejectedSubmit FAILED
java.lang.AssertionError: second sort() still blocked after 10 s, expected RejectedExecutionException
at __randomizedtesting.SeedInfo.seed([DEADBEEF:871EC74E6A158128]:0)
at org.apache.lucene.util.TestOfflineSorterRejectedSubmit.testSortAfterRejectedSubmit(TestOfflineSorterRejectedSubmit.java:61)
# where the blocked call sits (an independent public-API test, identical OfflineSorter.java, assertions off)
second sort() on shut-down executor: sort() did not return within 30 s; thread state=WAITING,
blocked in Semaphore.acquire from OfflineSorter.readPartition=true, executor active=0 queued=0
at java.base/java.util.concurrent.Semaphore.acquire(Semaphore.java:318)
at org.apache.lucene.util.OfflineSorter.readPartition(OfflineSorter.java:432)
at org.apache.lucene.util.OfflineSorter.sort(OfflineSorter.java:300)
# standalone runner, seed DEADBEEF: main 645d4fe14a20 fails with -ea and -da (about 11 s); release 10.6.0 fails;
# with the fix the test passes- The author's same-day follow-up, "add try/finally to make sure semaphore is released on exceptions", fixed every other exception path; this one was missed.
- The javadoc calls
maxPartitionsInRAM"the maximum concurrent in-memory partitions" and sets no condition on the executor. - A shut-down executor turns a fail-fast exception into an indefinite wait; Lucene's
TaskExecutortreats rejection as something that "only reduces parallelism".
The full argument266 words
- Intent, from the history:
25f1dd2f9b2("LUCENE-7792: add try/finally to make sure semaphore is released on exceptions", 2017-04-26, about three hours after the feature commit) added the releases now atOfflineSorter.java:301-304and:479-484and moved the task's release intofinally(it wrote thereadPartitionrelease as a success flag checked infinally; #14913 rewrote it as acatchin 2025, with the same behaviour). Within onesort()call nothing acquires after either new release (the loop breaks, orsort()rethrows), so they only matter to later calls on the same instance: keeping the permits balanced across calls, failed ones included, was the intent. The rejected submit is the one exception path it missed. - Contract: the constructor javadoc (
:225-229): "If you pass a non-nullExecutorServicethen it will be used to run sorting operations that can be run concurrently, and maxPartitionsInRAM is the maximum concurrent in-memory partitions." The leak lowers that maximum silently, down to 0.ExecutorService.submitthrowsRejectedExecutionException"if the task cannot be scheduled for execution". - Nothing says single use:
sort()resets its state per call (:289), there is noclose()and no "already used" check, and thesort()javadoc is only "Sort input to a new temp file, returning its name." (:286). - Precedent:
TaskExecutorcatchesRejectedExecutionExceptionand runs the task on the caller "to ensure a rejecting executor only reduces parallelism and does not result in failure" (TaskExecutor.java:55-67; #10032 / LUCENE-8989, #13622). - Tests:
TestOfflineSorteruses only unbounded pools (LinkedBlockingQueue), builds a fresh sorter per test and covers neither a rejection nor reuse after a failure.
The devil's-advocate check156 words
The strongest case for "intended" (rated moderate by the cross-model reviews; the devil's-advocate verifier found that none of its parts holds, the internal tag lowering only the priority): OfflineSorter is @lucene.experimental and @lucene.internal, the caller owns the executor and its capacity, every in-tree use is one-shot, and a maintainer could declare that a sorter must be thrown away after any exception; shutting down the executor you handed in is arguably misuse.
Why it fails: the internal tag speaks to API stability, not to the documented executor mode misbehaving; the 2017 follow-up shows the permits were meant to stay balanced across calls on exceptions; and even if reuse after a failure were declared unsupported, a silent, permanent drop of the documented concurrency limit (with k > 1 there is no exception at all) and a fail-fast exception turning into an indefinite wait are hard to call design. A docs-only resolution is still a plausible upstream outcome.
Recommended Release the permit when submit throws, then rethrow (validated)
Guard the hand-off in sort(): if exec.submit(job) throws, release the partition's permit and rethrow (+11/-1 lines in OfflineSorter.java). The failing call still throws, and the sorter keeps all its permits. It passes the repro and TestOfflineSorter with assertions on and off.
Callable<Partition> job = new SortPartitionTask(trackingDir, part);
Future<Partition> future;
try {
future = exec.submit(job);
} catch (Throwable t) {
// the task will never run, so its finally clause cannot release the permit
if (partitionsInRAM != null) {
partitionsInRAM.release();
}
throw t;
}
segments.add(future);All options and trade-offs3 options ยท 596 words
One fix was validated through gradle in a scratch worktree of 645d4fe14a20; running a rejected task on the caller is an alternative that was not run; a javadoc note is a complement only.
Wrap segments.add(exec.submit(job)) (OfflineSorter.java:311): when submit throws, the task will never run, so give its permit back before rethrowing. The merge hand-off (:398) needs no change: it holds no permit.
Callable<Partition> job = new SortPartitionTask(trackingDir, part);
Future<Partition> future;
try {
future = exec.submit(job);
} catch (Throwable t) {
// the task will never run, so its finally clause cannot release the permit
if (partitionsInRAM != null) {
partitionsInRAM.release();
}
throw t;
}
segments.add(future);Trade-offs
- Only the path that leaks today changes; the failing call still throws the executor's exception, and the next call behaves like the first.
- Catching
Throwablealso covers anOutOfMemoryErrorthrown bysubmit(the same leak, by code reading only); catching onlyRejectedExecutionExceptionwould be narrower. - An executor that threw from
submitafter it had already accepted the task would get a double release; theExecutorServicecontract says rejection means the task "cannot be scheduled for execution", so this is theoretical. partitionsInRAMis never null (:250); the null check only mirrors the existing release sites.- Scope: it covers a
submitthat throws. A rejection handler that drops the task without throwing (DiscardPolicy,DiscardOldestPolicy, orCallerRunsPolicyonce the pool is shut down, as the JDK'sThreadPoolExecutorjavadoc documents), or tasks removed byshutdownNow(), leave a future that never completes and a permit that never comes back; thesort()call that submitted the task then waits itself (for the future ingetPartition,OfflineSorter.java:660-662, or for the permit in the nextreadPartition). By code reading, not run. No change at the submit site can see that; it is the documented effect of those handlers, not this bug.
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 andTestOfflineSorterruns 15 tests, 2 nightly-only skipped, 0 failures, with assertions on and off (an independent re-run in another worktree gave the same result, the minimal test method taking 0.07 s);checkGoogleJavaFormat,forbiddenApisMain,forbiddenApisTest,ecjLintMainandecjLintTestpass. - Earlier checks: the same fix with the finder's test 20/20 over 10 iterations and in the standalone runner on
645d4fe14a20with assertions on and off; an independent verifier's equivalent patch (same code, another variable name) with its 4-test class (including two rejections withmaxPartitionsInRAM = 2and the shut-down case) 12/12, and 4/4 at the adjudication.
On RejectedExecutionException, call the SortPartitionTask in sort() and wrap the result in a completed future; its finally then releases the permit. Treat the merge hand-off (:398) the same way for consistency.
Trade-offs
A rejecting executor then only reduces parallelism, as in TaskExecutor (TaskExecutor.java:55-67), and the first sort() succeeds too. But it changes visible behaviour: sort() would succeed on an executor that was shut down, which callers may use to stop work, and the regression test would have to assert success instead of the exception. Not validated.
A javadoc note on sort() or the 8-argument constructor.
Trade-offs
Leaves the silent loss of concurrency for maxPartitionsInRAM > 1 and the indefinite wait in place.
Downstream, the risk is one unusable sorter object: a service that kept a sorter across requests on a bounded, rejecting executor would see later sorts wait until interrupted; a read-only, non-exhaustive code search found no downstream use of the executor constructor.
TLA+ model checking (TLC) of a protocol model of the sorter, with "the executor may reject a task" switched on, found a 4-state trace that ends with a permit missing after sort() failed. A JUnit on the stock jar confirmed it, and independent reproducers, read-only cross-model reviews, an adjudication and an audit checked it.
The method in full160 words
Method: formal (TLA+ model checking, then a public-API JUnit).
- Model.
OfflineSortProto.tlamodels the main thread, thepartitionsInRAMsemaphore, a FIFO executor with W workers, the partition-sort and merge tasks, the merge cascade and the failure cleanup. Invariant PermitsBack: oncesort()has ended and every task has finished, the permits equalmaxPartitionsInRAM. - Result. With "submit may be rejected" on, TLC reports a 4-state violation (acquire, submit rejected, cleanup, failed with 0 permits) in all 4 rejection configurations (W in {0, 1},
maxPartitionsInRAMin {1, 2}); without rejection it holds in all 24 configurations with one injected I/O error or interrupt. - Confirmation. A JUnit on the stock main jar (a saturated bounded pool, then a retry) failed with assertions on and off and on releases back to 6.6.0. An independent reproducer, a devil's-advocate verifier, read-only cross-model reviews (OpenAI GPT-5.6), an adjudication and a credibility audit checked it; a follow-up ran the version history, a prior-report search and a security screen.
No prior report found (150 queries on 2026-10-26 over GitHub, JIRA, the mailing lists and the web; rechecked on GitHub 2026-10-31). Closest: a 2017 CI hang in the same acquire, from a different leak path that the 2017 follow-up commit fixed the same day.
Searches3 searches
Verdict new: no issue, PR, JIRA entry, list thread, commit message or downstream issue describes OfflineSorter losing a permit when submit throws, or a later sort() on the same sorter hanging; no open PR touches OfflineSorter.java. Caveat: a strict reviewer may call this an unfinished part of the 2017 fix; it has its own fix site (the submit in sort()), so it counts as a separate, unreported bug.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream search engines), JIRA archive, mailing lists, web | 2026-10-26 | two runs of a scripted prior-report search (101 queries) and 49 manual queries: every apache/lucene issue and PR that mentions OfflineSorter, all-GitHub searches for maxPartitionsInRAM and partitionsInRAM (0 results), the changed files of all 411 open apache/lucene PRs, git log -S on the permit code | no report; closest #8843 (LUCENE-7792, added the executor; never discusses what sort() should do when submit rejects, or reusing a sorter; its only rejection is the RejectedExecutionException that a proposed same-thread executor throws once shut down), a 2017-04-26 dev@ CI mail (TestBKD.testWithExceptions hung in readPartition: same symptom, different leak path, fixed by 25f1dd2f9b2); precedent only: #10032 and #13622 (TaskExecutor), apache/solr#4236 and SLING-13130 (the same idiom elsewhere) |
| Verifier's own searches | 2026-10-26 | GitHub searches for OfflineSorter, OfflineSorter ExecutorService, RejectedExecutionException, semaphore, maxPartitionsInRAM; LUCENE-7792 read in full | no report |
| GitHub (recheck) | 2026-10-31 | apache/lucene issues and PRs for OfflineSorter (newest first), partitionsInRAM, maxPartitionsInRAM, "OfflineSorter RejectedExecutionException", "OfflineSorter hang"; open PRs mentioning OfflineSorter | nothing new: newest OfflineSorter issue #12665 and only PR #12712 (both 2023); 0 results for the rest; no open PR |
Introduced by LUCENE-7792 (db92a9efc2ee, 2017-04-26), which added the executor option; the same-day follow-up 25f1dd2f9b2 guarded the other exception paths but not the submit. First released in 6.6.0; every release through 10.6.0 and main is affected.
Versions tested4 rows
Introduced by LUCENE-7792 "add optional concurrency to OfflineSorter" (now GitHub issue #8843; committed from JIRA patches, no PR; author Mike McCandless, CHANGES credits Dawid Weiss and Mike McCandless): main db92a9efc2ee and branch_6x backport bb8dd7a9f98d, 2017-04-26. git blame at 645d4fe14a20 gives the submit (OfflineSorter.java:311) and the acquire (:430-433) to db92a9efc2ee. The follow-up 25f1dd2f9b2 (branch_6x e83829478e8) added the other releases the same day; both shipped together in 6.6.0 and 7.0.0. No later commit touched the submit. #14913 (27d1272984f, Simon Cooper, 2025-07-14, "Remove a few more boolean success flags"; on main and branch_11x, in no release yet: 10.6.0 still has the flags) rewrote the readPartition release (:479-484) and the cleanup in sort() (:366-369) from a success flag checked in finally into today's catch (Throwable t), with the same behaviour. The releases between the executed ones are inferred: git log -L shows the submit lines unchanged since db92a9efc2ee.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0 | affected | minimal test: second sort() still blocked after 10 s (main with assertions on and off) |
6.6.0, 6.6.6, 7.0.0, 7.7.3, 8.11.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2; main ea57360912ed | affected | finder's test: retry after a rejected submit does not return within 20 s, executor idle (assertions off too on 6.6.0, 9.12.3, 10.5.2 and main) |
| 6.5.1 | no API | no executor constructor: the test does not compile; an executor-free reuse control passes |
| fix (release on reject) | not affected | minimal test passes (0.07 s); TestOfflineSorter 15 run, 0 failures |
An independent reproducer, a devil's-advocate verifier, an adjudication and a credibility audit reproduced it on stock builds; read-only cross-model reviews (OpenAI GPT-5.6) agreed with the root cause. This doc's gradle run reproduced it and validated the fix.
Checks and verdicts414 words
Every executed check ran on stock builds; the cross-model reviewers could only read code, and separate runs re-ran the repro. No refute-lens verdict from the other model family exists (two attempts returned none).
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+ model, JUnit) | candidate | TLC: PermitsBack violated in all 4 rejection configurations, holds in all 24 fault-only ones. JUnit (saturated bounded pool, then a retry) fails on main with assertions on and off, 5 of 5 iterations, and on 10.5.2, 9.12.3, 8.11.2 and 6.6.0; the fix passes 20/20. |
| cross-model review (OpenAI GPT-5.6, intended lens) | real, medium | Read-only, could not run code; root cause agreed; the works-as-designed argument rated moderate. The refute lens returned no verdict in two attempts (tool failures), counted neither way. |
| Reproducer (own JUnit, another package) | real | 4 tests, public API only: the retry after a rejection, a second rejection with maxPartitionsInRAM = 2 (after the first, output still correctly sorted) and a second sort on a shut-down executor all block; the control passes. Main with assertions on and off, 10.5.2, 9.12.3, 8.11.2; its own one-site fix 12/12. |
| Cross-model review (OpenAI GPT-5.6, root-cause lens) | uncertain, medium | Uncertain only because it could not run code; root cause agreed (the submit is the one ownership gap; the merge submit holds no permit). Separate runs: the finder's test fails on main and 10.5.2, passes with the fix; upstream TestOfflineSorter 13/13 with the fix. |
| Devil's advocate | real, low severity | No intended, documented, misuse or single-use argument survived; its own probe shows one permit lost per rejection (maxPartitionsInRAM = 2) and the shut-down case on main and 10.5.2; 20/20 with the fix. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's test with a new seed on main with assertions on and off (4 run, 3 failed, control passes), the finder's test, and the independent reproduction's fix check (4/4). |
| Follow-up | history, prior art, security | Release sweep 6.5.1 to 10.5.2 and main (plus the independent reproducer's test on 6.6.0: 3 of 4 fail, control passes), a 150-query prior-report search (new) and a security screen (none) with an executed probe. |
| Credibility audit | keep | The independent reproduction's test on main with assertions off and a new seed: 4 run, 3 failed, control passes; stack in Semaphore.acquire from readPartition. |
| This doc (gradle, scratch worktree) | reproduced, fix validated | Minimal test through gradle on 645d4fe14a20 with assertions on and off, and on release 10.6.0; the fix passes the test and TestOfflineSorter. |
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 528 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,248 words
- Registry title
- OfflineSorter loses a partitionsInRAM permit whenever its ExecutorService rejects a partition-sort task, so a later sort() on the same sorter waits forever even though the executor is idle (released since 6.6.0)
- Area
- util / OfflineSorter concurrent sort (partitionsInRAM permits on a rejected submit)
- Priority
- Low, rank 813: OfflineSorter hangs only when an opted-in executor rejects work and the same instance is then reused, with no in-tree caller, a contrived reuse-after-failure hang like LFV-250.
- Impact class
- hang
- Impact note
- Only applications that build OfflineSorter (tagged @lucene.experimental and @lucene.internal) with the 8-argument constructor and their own ExecutorService that can reject work (a bounded ThreadPoolExecutor with the default AbortPolicy that other work has filled, or an executor that has been shut down), and that call sort() again on the same OfflineSorter instance after the rejection: each rejected partition task permanently removes one of the maxPartitionsInRAM permits, so the first k-1 rejections silently lower sort concurrency and after the k-th every later sort() on that instance parks in Semaphore.acquire until interrupted (no CPU spin, no wrong result, no index file touched; the default constructors cannot hit it and no in-tree caller passes an executor); ordinary scale, every release 6.6.0 to 10.5.2 and main.
- Summary
- You give OfflineSorter your own bounded thread pool, one sort() fails with RejectedExecutionException because the pool was full (or shut down), and you expect the same sorter to work again once the pool is idle, since every other exception path gives the partition's partitionsInRAM permit back. Instead the permit that readPartition took for the rejected partition is never released (only the task's finally block releases it, and a rejected task never runs; the semaphore belongs to the sorter instance), so with maxPartitionsInRAM = 1 the next sort() on that sorter waits forever in Semaphore.acquire with an idle executor (executed: 'sort() did not return within 20 s; waiting for a partition permit: true; executor active=0 queued=0'). With maxPartitionsInRAM = k each rejection costs one permit and after k rejections every later sort() hangs; with a shut-down executor, a call that should fail fast with RejectedExecutionException waits instead.
- Introduced
- db92a9efc2ee (LUCENE-7792 'add optional concurrency to OfflineSorter', by GitHub mikemccand, master; branch_6x backport bb8dd7a9f98d; committed from JIRA patches, no GitHub PR; the issue is now #8843) [read: git blame at main attributes the unguarded segments.add(exec.submit(job)) (:311) and the acquire in readPartition (:430-433) to it; the same-day follow-up 25f1dd2f9b2 (branch_6x e83829478e8) 'LUCENE-7792: add try/finally to make sure semaphore is released on exceptions' added releases for the empty last read, for a readPartition failure and in SortPartitionTask's finally, but left exec.submit(job) unguarded; exec: 6.5.1 has no executor constructor (compile error; its exec-free reuse control passes), 6.6.0 fails] (2017-04-26)
- First affected version
- 6.6.0 [exec] (first release with the ExecutorService constructor, via the branch_6x backport; 7.0.0 [exec] is the first from the master line; 6.5.1 has no such constructor, executed compile_error)
- Affected versions
- every release that has the ExecutorService constructor, 6.6.0 through 10.5.2, plus main ea57360912ed (not fixed; upstream main e1c4184cb98 of 2026-10-26 still has the unguarded submit [read]), and only with a non-null executor that rejects. Executed with the finder repro (1 of 2 tests fails, the no-rejection reuse control passes): 6.6.0 (-ea and -da), 6.6.6, 7.0.0, 7.7.3, 8.11.2, 8.11.4, 9.0.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) [exec]; the independent reproduction's own public-API test fails 3 of 4 on main (-ea and -da), 10.5.2, 9.12.3, 8.11.2 and 6.6.0 [exec]; the releases between the executed ones are inferred: git log -L shows the submit lines in sort() untouched since db92a9efc2e [read] (the 8.9.0 to 8.11.4 tags are not in the history clone; those jars came from the release cache)
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that stayed undecided, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- formal: TLA+/TLC
- Upstream and prior reports
- none found (2026-10-26, the prior-report record verdict new: 150 queries, two scripted prior-report searches with 101 queries over GitHub issues, PRs and commits, downstream repositories, the dev, java-user, issues and commits lists, the JIRA archive and the web, plus 49 manual GitHub, list, JIRA, web and history queries, among them every apache/lucene issue and PR that mentions OfflineSorter, all-GitHub searches for maxPartitionsInRAM and partitionsInRAM (0 results), the changed-file lists of all 411 open apache/lucene PRs and git log -S on the permit code; 23 hits read, all same_bug false). Closest items, none this bug: #8843 (LUCENE-7792, added the executor and the semaphore; its 14 comments never discuss a rejecting executor, a rejected submit or reusing a sorter); a 2017-04-26 dev@ CI mail (Lucene-Solr-6.x build 3373: TestBKD.testWithExceptions parked in Semaphore.acquire inside readPartition), a different leak path (an exception while reading or sorting a partition, inside one sort() call) that 25f1dd2f9b2 fixed the same day while leaving exec.submit unguarded; #10032 (LUCENE-8989) and PR #13622 (TaskExecutor runs a rejected task on the caller thread; precedent only, OfflineSorter does not use TaskExecutor); the same acquire-then-submit idiom in other projects (apache/solr#4236, SLING-13130). No open PR touches OfflineSorter.java; the last change to the file on upstream main is a8888cade78 (#15323, 2025-10-10)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree and through a standalone runner, with assertions on and off; with the fix it passes, as doesTestOfflineSorter(15 run, 2 nightly-only skipped, 0 failures). Release 10.6.0 fails the minimal test. The hunt ran on mainea57360912ed(OfflineSorter.javais identical) and swept the releases 6.5.1 to 10.5.2. Upstream main2f424fd428d(2026-10-30),branch_11x603af870375andbranch_10_67e5aec4370ecarry the same unguarded submit (read from the source).
- Finding record
a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java at ea57360912ed: :49-50 (@lucene.experimental, @lucene.internal), :225-229 (constructor javadoc), :246-250 (default SameThreadExecutorService, one Semaphore per instance), :289 (sortInfo reset per call), :301-304 (empty-read release), :311 (unguarded submit), :398 (merge submit, no permit), :430-433 (acquire), :479-484 (readPartition failure release), :652-656 (task finally release); lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:55-67 (rejection runs the task on the caller thread)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:49-50, :76, :225-229, :246-250, :286, :289, :298-311, :363-369, :398, :430-433, :478-484, :652-656a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/SameThreadExecutorService.java (checkShutdown: rejects only after shutdown)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:49-50, :202-222, :246-249a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/SortingStrategy.java:113; lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:165, SortedInputIterator.java:177, analyzing/AnalyzingSuggester.java:397a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/ExternalRefSorter.java:35-36 (@lucene.experimental, @lucene.internal), :45 (public constructor taking an OfflineSorter), :79 (sorter.sort)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:225-229, :286, :289, :301-304, :479-484a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:55-67a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestOfflineSorter.java:78-91
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:55-67a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:250, :311, :398JDK 25 src.zip java.base/java/util/concurrent/ThreadPoolExecutor.java (CallerRunsPolicy, DiscardPolicy, DiscardOldestPolicy javadoc); ExecutorService.shutdownNow javadoc; a Lucene main checkout (645d4fe14a20) OfflineSorter.java:660-662
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:8312-8313 (LUCENE-7792 entry)a Lucene clone origin/main 2f424fd428d, origin/branch_11x 603af870375 (submit at :311), origin/branch_10_6 7e5aec4370e and releases/lucene/10.6.0 (submit at :307) [read]
- TestOfflineSorterRejectedSubmit.java (one test class):
repro/LFV-384/src/org/apache/lucene/util/TestOfflineSorterRejectedSubmit.java(67 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's output.
assets/LFV-384/bug.png(528 KB)
