All findings · Lucene finding · LFV-384 · offlinesorter-rejected-submit-leaks-partition-permit

OfflineSorter loses a partition permit when its executor rejects a task; once none are left, sort() on the same sorter waits until interrupted

When the ExecutorService you gave OfflineSorter rejects a partition-sort task, sort() throws but never gives back the in-memory partition permit it took. The permits belong to the sorter, so with maxPartitionsInRAM = 1 the next sort() on it waits until interrupted, even with an idle executor.

Summary

Priority
Low, rank 813OfflineSorter 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
hang
Since
6.6.0 (LUCENE-7792, 2017)introduced 2017-04-26 · about 9 years
Affected
every release 6.6.0 to 10.6.0, and main
Status
documented
Found by
formal: TLA+ model checking of the sorter's permit protocol

AI-assisted page: this finding was first signalled by a formal tool's own output 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

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.

How OfflineSorter limits memory

  1. OfflineSorter sorts 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.
  2. With the 8-argument constructor you can pass your own ExecutorService: partitions are then sorted on its threads, and maxPartitionsInRAM caps how many are held in memory at once.
  3. 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.
Diagram of the bug: an OfflineSorter with maxPartitionsInRAM = 1 and an executor that has been shut down. The first sort() takes the only permit for its one partition and submits the sort task; the executor rejects it, sort() throws RejectedExecutionException, and the permit is never returned because the task that would return it never ran. The second sort() on the same sorter waits for a permit that never comes back, while the executor is idle.
Each partition takes a permit from the sorter's pool and its sort task gives it back when it finishes. A rejected task never runs, so its permit never comes back; with 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.

The finding, step by step

  1. Build an OfflineSorter with your own executor, here a single-thread executor that is already shut down, and maxPartitionsInRAM = 1. Sort a one-record file: sort() throws RejectedExecutionException, as it should.
  2. 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).
  3. 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.
  4. 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.

Who is affected

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.

The code (main 645d4fe14a20)

  • A permit per partition: readPartition starts with partitionsInRAM.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 the finally of SortPartitionTask.call (:652-656).
  • The unguarded hand-off (:309-311):
Callable<Partition> job = new SortPartitionTask(trackingDir, part);

segments.add(exec.submit(job));
  • If submit throws, call() never runs, so its finally never runs. The catch (Throwable t) of sort() (:366-369) deletes the temp files and rethrows; it does not touch the semaphore.
  • The loss outlives the call: partitionsInRAM is a private final Semaphore (:76) created once per instance (:250). sort() resets its per-call state (sortInfo = new SortInfo(), :289); there is no close() and no "already used" check. The next call opens its input (:298) and parks in acquire (:432), holding that input open.
  • Not affected: the merge hand-off (:398) holds no permit. The 2- and 3-argument constructors pass null, so the sorter creates its own SameThreadExecutorService with one permit (:246-249); nothing outside the sorter can reach that executor, and it rejects only after shutdown().

The numbers

  • maxPartitionsInRAM = 1, one-record input, executor already shut down: the first sort() throws RejectedExecutionException; the second is still blocked after 10 s, in Semaphore.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 with executor 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() throws ThreadInterruptedException (: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 ExecutorService that can reject: one that has been shut down, or a bounded ThreadPoolExecutor with the default AbortPolicy that other work has filled. It then has to call sort() again on the same OfflineSorter after the RejectedExecutionException.
  • Effect. Each rejected partition task silently removes one of the maxPartitionsInRAM permits. Before the last is gone, sorting is only less concurrent and stays correct. After that, every later sort() 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, SortedInputIterator and AnalyzingSuggester use the default constructors, and BKDWriter passed null, 0 back 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) calls sort() from iterator(), 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.experimental and @lucene.internal.
  • Likelihood: rare. It needs a rejecting executor plus reuse of the failed sorter; nobody has reported it since 2017.

Minimal repro

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.

TestOfflineSorterRejectedSubmit.java (one test class)67 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.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);
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestOfflineSorterRejectedSubmit -Ptests.seed=DEADBEEF
Expected
The second sort() throws RejectedExecutionException at 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 in Semaphore.acquire inside OfflineSorter.readPartition.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; release 10.6.0: fails; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; a semaphore count that never comes back)
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • 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 TaskExecutor treats 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 at OfflineSorter.java:301-304 and :479-484 and moved the task's release into finally (it wrote the readPartition release as a success flag checked in finally; #14913 rewrote it as a catch in 2025, with the same behaviour). Within one sort() call nothing acquires after either new release (the loop breaks, or sort() 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-null ExecutorService then 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.submit throws RejectedExecutionException "if the task cannot be scheduled for execution".
  • Nothing says single use: sort() resets its state per call (:289), there is no close() and no "already used" check, and the sort() javadoc is only "Sort input to a new temp file, returning its name." (:286).
  • Precedent: TaskExecutor catches RejectedExecutionException and 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: TestOfflineSorter uses 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.

Suggested fix

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.

Release the permit when submit throws, then rethrow (validated) (recommended)

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 Throwable also covers an OutOfMemoryError thrown by submit (the same leak, by code reading only); catching only RejectedExecutionException would be narrower.
  • An executor that threw from submit after it had already accepted the task would get a double release; the ExecutorService contract says rejection means the task "cannot be scheduled for execution", so this is theoretical.
  • partitionsInRAM is never null (:250); the null check only mirrors the existing release sites.
  • Scope: it covers a submit that throws. A rejection handler that drops the task without throwing (DiscardPolicy, DiscardOldestPolicy, or CallerRunsPolicy once the pool is shut down, as the JDK's ThreadPoolExecutor javadoc documents), or tasks removed by shutdownNow(), leave a future that never completes and a permit that never comes back; the sort() call that submitted the task then waits itself (for the future in getPartition, OfflineSorter.java:660-662, or for the permit in the next readPartition). 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 and TestOfflineSorter runs 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, ecjLintMain and ecjLintTest pass.
  • Earlier checks: the same fix with the finder's test 20/20 over 10 iterations and in the standalone runner on 645d4fe14a20 with assertions on and off; an independent verifier's equivalent patch (same code, another variable name) with its 4-test class (including two rejections with maxPartitionsInRAM = 2 and the shut-down case) 12/12, and 4/4 at the adjudication.

Run a rejected partition task on the calling thread, like TaskExecutor (not run)

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.

Document that a sorter must not be reused after an exception (complement only)

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 note

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.

How it was found

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.tla models the main thread, the partitionsInRAM semaphore, a FIFO executor with W workers, the partition-sort and merge tasks, the merge cascade and the failure cleanup. Invariant PermitsBack: once sort() has ended and every task has finished, the permits equal maxPartitionsInRAM.
  • 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}, maxPartitionsInRAM in {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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream search engines), JIRA archive, mailing lists, web2026-10-26two 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 codeno 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 searches2026-10-26GitHub searches for OfflineSorter, OfflineSorter ExecutorService, RejectedExecutionException, semaphore, maxPartitionsInRAM; LUCENE-7792 read in fullno report
GitHub (recheck)2026-10-31apache/lucene issues and PRs for OfflineSorter (newest first), partitionsInRAM, maxPartitionsInRAM, "OfflineSorter RejectedExecutionException", "OfflineSorter hang"; open PRs mentioning OfflineSorternothing new: newest OfflineSorter issue #12665 and only PR #12712 (both 2023); 0 results for the rest; no open PR

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal 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 affectedfinder'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 APIno executor constructor: the test does not compile; an executor-free reuse control passes
fix (release on reject) not affectedminimal test passes (0.07 s); TestOfflineSorter 15 run, 0 failures

Independent checks

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

Each independent check and its verdict
CheckVerdictWhat it found
Finder (TLA+ model, JUnit) candidateTLC: 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, mediumRead-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) real4 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, mediumUncertain 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 severityNo 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, highRe-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, securityRelease 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 keepThe 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 validatedMinimal test through gradle on 645d4fe14a20 with assertions on and off, and on release 10.6.0; the fix passes the test and TestOfflineSorter.

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

Finding record

LFV-384, snapshot of 2026-11-06

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 does TestOfflineSorter (15 run, 2 nightly-only skipped, 0 failures). Release 10.6.0 fails the minimal test. The hunt ran on main ea57360912ed (OfflineSorter.java is identical) and swept the releases 6.5.1 to 10.5.2. Upstream main 2f424fd428d (2026-10-30), branch_11x 603af870375 and branch_10_6 7e5aec4370e carry the same unguarded submit (read from the source).

Lucene and upstream sources

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-656
  • a 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-249
  • a 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:397
  • a 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-484
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:55-67
  • a 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-67
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:250, :311, :398
  • JDK 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]

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