All findings · Lucene finding · LFV-357 · offlinesorter-failure-cleanup-ignores-inflight-tasks

OfflineSorter with an ExecutorService can leave temp files behind when sort() fails

With your own ExecutorService, a failed OfflineSorter.sort() (disk error, truncated input, interrupt) deletes only the temp files that exist at that moment and throws, without waiting for the sort jobs it already queued. Those jobs then run and can leave <prefix>_sort_N.tmp files that nothing deletes, in every release since 6.6.0.

Summary

Priority
Low, rank 1104OfflineSorter leaves stray temp files behind, a minor resource leak that happens only after a failed or interrupted run with an opt-in executor, the same exception-path leak profile as LFV-309.
Impact
minor
Since
6.6.0 (LUCENE-7792, commit db92a9efc2ee, 2017-04-26)introduced 2017-04-26 · about 9 years
Affected
6.6.0-10.6.0 and main, only with an executor
Status
documented
Found by
formal: TLA+ model checking (TLC)

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

sort() cleans up on failure, but only the temp files that exist when it fails. A job still waiting in your pool writes its file later, and nobody deletes it.

Background: how OfflineSorter sorts

  1. OfflineSorter (org.apache.lucene.util) sorts more data than fits in memory: it reads the input in partitions that fill its RAM buffer, sorts each partition into a temp file named <prefix>_sort_N.tmp, and merges those files into one sorted output.
  2. Since 6.6.0 you can pass an ExecutorService to the 8-argument constructor: partition sorts and merges then run as background jobs, at most maxPartitionsInRAM partitions in memory at a time. Without one, every job runs on the calling thread.
  3. If sort() fails, it deletes the temp files it created and rethrows; its code says "So we can remove any partially written temp files on exception".
Diagram of the bug: OfflineSorter.sort() with an ExecutorService reads 1,016 records as partition 1 and queues its sort job; reading partition 2 hits a truncated record and throws EOFException; the cleanup deletes the temp files that exist at that moment (none) and sort() throws; the queued job then runs and writes foo_sort_1.tmp (1,016 records), which nothing deletes. Without an executor the job runs before the failure and its file is deleted by the cleanup. The fix marks the sort aborted and waits for submitted jobs before cleaning up.
A failed sort with a thread pool: partition 1's job waits in the queue, reading partition 2 fails, sort() cleans up the files that exist (none) and throws, and the job then writes foo_sort_1.tmp, which nothing deletes. 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 ExecutorService (the 8-argument constructor, maxPartitionsInRAM 2) and a 1 MB buffer, and sort 1,100 records of 1,000 bytes whose last record is cut short. Partition 1, 1,016 records, goes to the pool as a background sort job.
  2. Before that job runs, reading partition 2 reaches the cut-off record and throws EOFException. You expect sort() to throw it and leave only the input file, as it does without an executor.
  3. Instead sort() deletes only the temp files that exist at that moment (none yet) and throws. It neither cancels nor waits for the queued job.
  4. The job then runs and writes foo_sort_1.tmp, 1,016 sorted records (1,018,048 bytes), which nothing ever deletes. With ordinary 1- and 2-thread pools, 32 to 39 of 320 injected I/O failures left files behind this way; without an executor, 0 of 160.

Who is affected

Code that passes its own ExecutorService to the 8-argument constructor of this @lucene.internal class and whose sort() fails while a job is still queued or running. No in-tree caller on main does; 9.8.0's BPIndexReorderer did when given a ForkJoinPool. Up to one leftover temp file per unfinished job; no wrong result, and the failed sort() returns at once.

Mechanism, terms and numbers677 words

OfflineSorter.sort() wraps its Directory in a TrackingDirectoryWrapper "So we can remove any partially written temp files on exception". With a non-null ExecutorService (the 8-argument constructor, since 6.6.0), each partition sort and each merge runs as a job on that executor, and a job creates its temp file only when it runs. When sort() fails, its catch blocks delete a snapshot of the files created so far and rethrow, without cancelling or waiting for the jobs already submitted, so jobs that run afterwards can leave files nobody deletes. Without an executor every job runs inside submit(), and the same failure leaves nothing. In the repro a truncated 1.1 MB input leaves foo_sort_1.tmp (1,016 sorted records) behind; with plain 1- and 2-thread pools, 32 to 39 of 320 injected I/O failures leaked files. Successful sorts are unaffected, the failed sort() returns at once and no result is wrong. The class is @lucene.internal and no in-tree caller on main passes an executor, so the reach is small. Waiting for the submitted jobs (after making queued ones no-ops) before the cleanup fixes it.

Terms

  • Partition: as many input records as fit in the RAM buffer; reading one takes a partitionsInRAM permit (OfflineSorter.java:430-433).
  • Job: a SortPartitionTask (sorts one partition into a temp file) or a MergePartitionsTask (merges up to maxTempFiles temp files), submitted with exec.submit(...).
  • TrackingDirectoryWrapper: a Directory wrapper that records the name of every file created through it; getCreatedFiles() returns a copy of those names.

The bug

  1. sort() starts with // So we can remove any partially written temp files on exception: and wraps the directory in a TrackingDirectoryWrapper (:295-296).
  2. With exec != null the constructor keeps the caller's executor (:240-245); otherwise it uses a SameThreadExecutorService, whose execute just runs the job (:246-248; SameThreadExecutorService.java:34-37).
  3. Each partition becomes a job, Callable<Partition> job = new SortPartitionTask(trackingDir, part); (:309), submitted with segments.add(exec.submit(job)); (:311); merges are submitted the same way (:394-398).
  4. A job creates its temp file only when it runs: dir.createTempOutput(tempFileNamePrefix, "sort", ...) in SortPartitionTask.call (:630-631) and in MergePartitionsTask.call (:698-700).
  5. On failure both catch blocks (:363-369) call IOUtils.deleteFilesSuppressingExceptions(..., trackingDir, trackingDir.getCreatedFiles()) and rethrow. That set is a copy taken at that moment (TrackingDirectoryWrapper.java:70-73), and nothing touches the futures in segments: no cancel, no get.
  6. A job that had not yet created its file does so after the cleanup. Its name lands in the tracking set, but nobody reads the set again, and sort() gives the caller neither the generated names nor a way to wait for the jobs. A partition file stays unless a later merge consumes it (:749-753); a merge whose inputs the cleanup already deleted creates its output (:698-700), fails to open an input and leaves that output empty.

Without an executor every job has run before the failure is caught, so the snapshot is complete. A successful sort is fine too: it waits for the final job (:350, future.get() at :662), which waits for all its inputs.

The numbers

Stock main, the repro's input and executor (a small probe, not the repro): partition 1 holds 1,016 records; right after sort() throws EOFException the directory holds only input_tmp_0.tmp and one job is still queued; once it runs, foo_sort_1.tmp is there for good: 1,018,048 bytes, 1,016 records, a valid footer. With exec == null the same input leaves only input_tmp_0.tmp.

Natural timing, no test gating: a harness that injects one IOException per run into the Directory or one of its inputs or outputs (at each of the first 40 such operations of a sort and at 40 randomly chosen later ones, 80 runs per configuration) leaked files in 32 (assertions on) and 39 (off) of 320 failed sorts with 1- and 2-thread FIFO pools, and in 0 of 160 without an executor. Other executed triggers: an injected read error 1,000 entries into partition 2, and an interrupt while sort() waits for a partition permit.

Exposure and likelihood319 words
  • Who is hit. Callers of the 8-argument constructor with a non-null ExecutorService, the documented way to sort concurrently. The default constructors are not affected.
  • Reach. OfflineSorter is tagged @lucene.experimental and @lucene.internal. On main, hunspell SortingStrategy and the suggesters (SortedInputIterator, AnalyzingSuggester, FSTCompletionLookup) use the 2- or 3-argument constructors. In release 9.8.0 BPIndexReorderer (lucene/misc) passed its ForkJoinPool (when one was set) to this constructor; 9.9.0 dropped that use (#12712). A code search of the default branches of Elasticsearch, OpenSearch and Solr found no use of OfflineSorter.
  • What happens. Per failed sort, at most one leftover <prefix>_sort_N.tmp for each job that had not yet created its file: at most maxPartitionsInRAM partition jobs plus any merge jobs already submitted. There can be fewer, because a merge that runs later deletes the partition files it consumes: with maxTempFiles 2, two queued partition jobs and their queued merge left one file, the merge's output. A leftover partition file is a complete sorted copy of the records it held (1,016 in the repro); a merge whose inputs the cleanup had already deleted fails and leaves an empty output file with no footer (both cases executed by the fact check's probes). The orphaned jobs also keep their CPU, I/O and partition buffer until they finish. The documented maxPartitionsInRAM * ramBufferSize bound is per sorter and still holds: a later sort() on the same sorter waits for those permits, and can wait forever if the executor never runs the orphaned jobs (for example after shutdownNow() dropped them; code reading).
  • What does not happen. No wrong or partial result (sort() throws), the failed sort() does not hang, no index file change; a successful sort is unaffected. Assertions on or off make no difference.
  • Likelihood. Rare: a sort must fail (disk error, damaged input, interrupt) while a job is in flight; under natural timing about 1 in 10 injected failures leaked.

Minimal repro

One JUnit test on the public API: a truncated input, a 1 MB buffer, and a small ExecutorService that queues jobs and runs them when the test says so (or when get() is called), standing in for a pool whose threads are busy. Deterministic (it draws no random values; every seed tried fails the same way), single thread.

TestOfflineSorterFailureCleanup.java (JUnit, one test)99 lines
package org.apache.lucene.util;

import java.io.EOFException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.RunnableFuture;
import java.util.concurrent.TimeUnit;
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;
import org.apache.lucene.util.OfflineSorter.BufferSize;
import org.apache.lucene.util.OfflineSorter.ByteSequencesWriter;

public class TestOfflineSorterFailureCleanup extends LuceneTestCase {

  public void testFailedSortLeavesNoTempFiles() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      String input;
      try (IndexOutput out = dir.createTempOutput("input", "tmp", IOContext.DEFAULT)) {
        ByteSequencesWriter writer = new ByteSequencesWriter(out);
        for (int i = 0; i < 1100; i++) {
          writer.write(new byte[1000]);
        }
        out.writeShort(Short.MAX_VALUE);
        CodecUtil.writeFooter(out);
        input = out.getName();
      }
      DeferredExecutor executor = new DeferredExecutor();
      OfflineSorter sorter =
          new OfflineSorter(
              dir,
              "foo",
              OfflineSorter.DEFAULT_COMPARATOR,
              BufferSize.megabytes(1),
              OfflineSorter.MAX_TEMPFILES,
              -1,
              executor,
              2);
      expectThrows(EOFException.class, () -> sorter.sort(input));
      executor.runQueued();
      // expected: only the input file is left; actual: also foo_sort_1.tmp, written by the partition task sort() had submitted
      assertEquals(List.of(input), List.of(dir.listAll()));
    }
  }

  private static final class DeferredExecutor extends AbstractExecutorService {
    private final List<Runnable> queued = new ArrayList<>();

    @Override
    protected <T> RunnableFuture<T> newTaskFor(Callable<T> task) {
      return new FutureTask<>(task) {
        @Override
        public T get() throws InterruptedException, ExecutionException {
          run();
          return super.get();
        }
      };
    }

    @Override
    public void execute(Runnable task) {
      queued.add(task);
    }

    void runQueued() {
      queued.forEach(Runnable::run);
    }

    @Override
    public void shutdown() {}

    @Override
    public List<Runnable> shutdownNow() {
      return List.of();
    }

    @Override
    public boolean isShutdown() {
      return false;
    }

    @Override
    public boolean isTerminated() {
      return false;
    }

    @Override
    public boolean awaitTermination(long timeout, TimeUnit unit) {
      return true;
    }
  }
}
Command
cp TestOfflineSorterFailureCleanup.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestOfflineSorterFailureCleanup -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
sort() throws EOFException and only the input file is left: [input_tmp_0.tmp], as without an executor.
Observed
expected:<[input_tmp_0.tmp]> but was:<[foo_sort_1.tmp, input_tmp_0.tmp]>: the queued job wrote foo_sort_1.tmp after the cleanup; the same with assertions off.

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

More files, environment, full output331 words

The input is 1,100 records of 1,000 bytes, then a record header that claims 32,767 bytes and has none, then a valid footer. BufferSize.megabytes(1) makes partition 1 take 1,016 records. DeferredExecutor runs a queued job only when the test calls runQueued() or when someone calls get() on its future; the second rule lets a fix that waits for submitted jobs complete instead of hanging. ByteBuffersDirectory numbers temp files with one counter, so the input is input_tmp_0.tmp and the leaked file foo_sort_1.tmp. The test sits in org.apache.lucene.util to match Lucene's test layout and uses no package-private member. As written it has no ASF license header and one comment line is too long for google-java-format; a copy with both fixed passes :lucene:core:check.

As the regression test: the same test, for example as a method in TestOfflineSorter.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on the 10.6.0, 10.5.2 and 9.12.3 releases
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestOfflineSorterFailureCleanup.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestOfflineSorterFailureCleanup -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestOfflineSorterFailureCleanup > testFailedSortLeavesNoTempFiles FAILED
    java.lang.AssertionError: expected:<[input_tmp_0.tmp]> but was:<[foo_sort_1.tmp, input_tmp_0.tmp]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:D7882769E7D7FE80]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.util.TestOfflineSorterFailureCleanup.testFailedSortLeavesNoTempFiles(TestOfflineSorterFailureCleanup.java:49)

1 test completed, 1 failed

# release jars 10.6.0 (assertions on and off), 10.5.2 and 9.12.3 (on): the same assertion message

# step by step (a small probe with the repro's input and executor, stock main, assertions off)
PROBE executor sort() threw EOFException; records read into partition 1 = 1016; files right after sort() = [input_tmp_0.tmp]; tasks still queued = 1
PROBE executor files after the queue ran = [foo_sort_1.tmp, input_tmp_0.tmp]
PROBE executor leaked foo_sort_1.tmp: 1018048 bytes, 1016 records, footer ok
PROBE exec=null sort() threw EOFException; records read into partition 1 = 1016; files right after sort() = [input_tmp_0.tmp]; tasks still queued = 0
PROBE exec=null files after the queue ran = [input_tmp_0.tmp]

Why it looks unintended

  • The code states the intent: "So we can remove any partially written temp files on exception" (OfflineSorter.java:295).
  • Without an executor the same failure leaves nothing; the executor was added as an optional speed-up, with no new duties for callers.
  • Leftover temp files were fixed as bugs before (LUCENE-4209); no test sorts with an executor and fails.
The full argument290 words
  • Intent stated in the code. No javadoc promise: sort() says only "Sort input to a new temp file, returning its name" (:286). The code states the goal at :295, again at :353-354 ("We should be explicitly removing all intermediate files ourselves unless there is an exception"), and in the catch blocks (:363-369).
  • The executor is presented as optional. Constructor javadoc: "If you pass a non-null ExecutorService then it will be used to run sorting operations that can be run concurrently" (:226-227); CHANGES: "OfflineSorter can now run concurrently if you pass it an optional ExecutorService" (lucene/CHANGES.txt:8312-8313). Nothing tells the caller to drain the executor or to delete files after a failure, and the caller never gets the generated names.
  • History. LUCENE-4209 (CHANGES :15305-15308) fixed temp files left behind in the system temp directory. In LUCENE-6829 (#7887, 2015) Mike McCandless wrote: "I cutover to TrackingDirectory in OfflineSorter to manage deleting temp files on exception". When BKDWriter used this class, TestBKD.testWithExceptions checked "BKDWriter should fully clean up after itself" (7.7.3, TestBKD.java:413). LUCENE-7792 (db92a9efc2ee) moved file creation into jobs and kept the one-shot cleanup; its same-day follow-up 25f1dd2f9b2 "add try/finally to make sure semaphore is released on exceptions" shows the failure paths were meant to be handled. The #8843 thread never discusses a failure with jobs in flight.
  • Tests. testBitFlippedOnInput1/2 use the 2-argument constructor and testBitFlippedOnPartition1/2 pass null (TestOfflineSorter.java:356, :414, :460, :528); randomExecutorServiceOrNull() is used only where the sort succeeds (:96, :120, :145, :551).
  • Legal trigger. Public constructor, a standard executor, BufferSize.megabytes(1); the class detects corrupt input on purpose (compare testBitFlippedOnInput2) and catches the interrupt explicitly (:363).
The devil's-advocate check194 words

The strongest case for "works as designed" (the intended-behaviour verifier and the cross-model reviews): the class is @lucene.experimental and @lucene.internal; the executor belongs to the caller, who can shut it down and delete files by prefix; the cleanup is best effort (deleteFilesSuppressingExceptions); there is no javadoc promise; and TaskExecutor waiting for sibling tasks after a failure (#12523) was labelled an enhancement. A maintainer may call the fix an improvement, or deprecate the executor option, which nobody in-tree has used since 9.9.0.

Why it fails: @lucene.internal is about API stability, not about cleanup being optional. Nothing documents a duty to drain the executor, and sort() gives the caller no completion signal and no list of the generated names: short of shutting down its own, possibly shared, executor or polling the Directory, it cannot tell when the orphaned jobs are done. The class already holds the futures and waits on them when the sort succeeds. "Best effort" covers a delete that fails, not a file created after the cleanup ran. The argument limits priority (low), not validity; this is an inference from the code, its comments and history, not a maintainer ruling.

Suggested fix

Recommended A. Abort, then wait for every submitted job before the cleanup

On failure, mark the sort aborted so jobs that have not started do nothing, then wait for every submitted job before deleting the created files (+49/-2 in OfflineSorter.java). The repro then passes with assertions on and off; TestOfflineSorter (incl. nightly), the suggest tests and :lucene:core:check pass. Trade-off: a failing sort() waits for running jobs.

All options and trade-offs4 options ยท 1,245 words

Option A was run through gradle in a scratch worktree of 645d4fe14a20 and is the suggested option: validated against main's tests, not against every legal executor, so it needs a maintainer's design review. B, C and D are alternatives a maintainer may prefer; none was tried. No open PR touches OfflineSorter.java. The one-test repro passes under any option that leaves no file once the queued job has run.

A. Abort, then wait for every submitted job before the cleanup (validated by the checks) (recommended)

sort() records every future it submits (partition and merge jobs) and a fresh aborted flag per call. Both catch blocks first call abortAndAwaitTasks(): set the flag, then get() every submitted future, ignoring the job's own failure, retrying on InterruptedException and restoring the interrupt flag at the end. Each job starts with: if aborted, return the permit (partition jobs) and throw CancellationException, so queued jobs create no file. Only then are trackingDir.getCreatedFiles() deleted.

diff --git a/lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java b/lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java
index a4d09ff674c..ffb9650a538 100644
--- a/lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java
+++ b/lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java
@@ -24,10 +24,12 @@ import java.util.Comparator;
 import java.util.List;
 import java.util.Locale;
 import java.util.concurrent.Callable;
+import java.util.concurrent.CancellationException;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
 import org.apache.lucene.codecs.CodecUtil;
 import org.apache.lucene.store.ChecksumIndexInput;
@@ -188,6 +190,10 @@ public class OfflineSorter {
   private final BufferSize ramBufferSize;
 
   SortInfo sortInfo;
+  // every task submitted by the current sort() call, so a failed sort can wait for them
+  private List<Future<Partition>> submitted;
+  // set when the current sort() call fails; tasks that have not started yet then do nothing
+  private AtomicBoolean aborted;
   private final int maxTempFiles;
   private final Comparator<BytesRef> comparator;
 
@@ -287,6 +293,8 @@ public class OfflineSorter {
   public String sort(String inputFileName) throws IOException {
 
     sortInfo = new SortInfo();
+    submitted = new ArrayList<>();
+    aborted = new AtomicBoolean();
     long startMS = System.currentTimeMillis();
 
     List<Future<Partition>> segments = new ArrayList<>();
@@ -308,7 +316,9 @@ public class OfflineSorter {
 
         Callable<Partition> job = new SortPartitionTask(trackingDir, part);
 
-        segments.add(exec.submit(job));
+        Future<Partition> future = exec.submit(job);
+        submitted.add(future);
+        segments.add(future);
         sortInfo.tempMergeFiles++;
         sortInfo.lineCount += part.count;
         levelCounts[0]++;
@@ -361,14 +371,40 @@ public class OfflineSorter {
       return result;
 
     } catch (InterruptedException ie) {
+      abortAndAwaitTasks();
       IOUtils.deleteFilesSuppressingExceptions(ie, trackingDir, trackingDir.getCreatedFiles());
       throw new ThreadInterruptedException(ie);
     } catch (Throwable t) {
+      abortAndAwaitTasks();
       IOUtils.deleteFilesSuppressingExceptions(t, trackingDir, trackingDir.getCreatedFiles());
       throw t;
     }
   }
 
+  /**
+   * Called when sort() fails: tasks that have not started yet become no-ops, and we wait for all
+   * submitted tasks to finish so that none of them creates a temp file after the cleanup.
+   */
+  private void abortAndAwaitTasks() {
+    aborted.set(true);
+    boolean interrupted = false;
+    for (Future<Partition> future : submitted) {
+      while (true) {
+        try {
+          future.get();
+          break;
+        } catch (InterruptedException _) {
+          interrupted = true;
+        } catch (ExecutionException | CancellationException _) {
+          break;
+        }
+      }
+    }
+    if (interrupted) {
+      Thread.currentThread().interrupt();
+    }
+  }
+
   /**
    * Called on exception, to check whether the checksum is also corrupt in this source, and add that
    * information (checksum matched or didn't) as a suppressed exception.
@@ -395,7 +431,9 @@ public class OfflineSorter {
         new MergePartitionsTask(trackingDir, new ArrayList<>(segmentsToMerge));
 
     segmentsToMerge.clear();
-    segments.add(exec.submit(task));
+    Future<Partition> future = exec.submit(task);
+    submitted.add(future);
+    segments.add(future);
 
     sortInfo.tempMergeFiles++;
   }
@@ -627,6 +665,12 @@ public class OfflineSorter {
 
     @Override
     public Partition call() throws IOException {
+      if (aborted.get()) {
+        if (partitionsInRAM != null) {
+          partitionsInRAM.release();
+        }
+        throw new CancellationException("sort was aborted");
+      }
       try (IndexOutput tempFile =
               dir.createTempOutput(tempFileNamePrefix, "sort", IOContext.DEFAULT);
           ByteSequencesWriter out = getWriter(tempFile, part.buffer.size())) {
@@ -681,6 +725,9 @@ public class OfflineSorter {
 
     @Override
     public Partition call() throws IOException {
+      if (aborted.get()) {
+        throw new CancellationException("sort was aborted");
+      }
       long totalCount = 0;
       for (Future<Partition> segment : segmentsToMerge) {
         totalCount += getPartition(segment).count;

Trade-offs

  • A failing sort() returns only after the running jobs have finished (bounded: at most maxPartitionsInRAM partition sorts plus running merges) and a worker has picked up each queued job (which returns at once). On a stalled or saturated caller-owned executor, or one shut down with shutdownNow() that drops queued jobs, the wait can be long or never end. The success path already waits on the executor the same way; today's failure path does not. For example, a sort() running on the only worker of its own executor already deadlocks on success (it waits for a job queued behind itself); with A it waits the same way on failure.
  • The wait continues through further interrupts (the flag is restored), so a second interrupt cannot cut a failing sort short.
  • Waiting before the snapshot also removes the unconfirmed getCreatedFiles() copy race.
  • The new fields are set per call but live on the instance, and the jobs read the flag when they run, so A requires one sort() at a time per sorter (sortInfo is shared the same way; testThreadSafety uses one sorter per thread). Passing the call's list and flag to the jobs would drop that requirement. submitted keeps the finished futures until the next call and could be cleared at the end.
  • The diff uses unnamed catch variables (catch (InterruptedException _), Java 22+): fine for main (minimum Java 25); a 10.x backport (minimum Java 21) needs named variables.
  • No API or format change; the success path adds one list append per job.

What the checks validated

Gradle with JDK 25 in a scratch worktree of 645d4fe14a20 (removed afterwards):

  • the repro fails on the stock build and passes with the fix, assertions on and off, also in its committable form (ASF header, long comment wrapped);
  • TestOfflineSorter: seed DEADBEEF 15 tests, 0 failures (2 nightly skipped); seed 5EED1234 with -Ptests.iters=5 75 tests, 0 failures; with -Ptests.nightly=true 15 tests, 0 failures, 0 skipped (incl. the executor-using testLargerRandom and testFixedLengthHeap);
  • :lucene:suggest:test: 31 classes, 236 tests, 0 failures, 7 skipped;
  • :lucene:core:check -x test with the committable test: BUILD SUCCESSFUL (format, license headers, ecj lint, forbidden APIs); with the test exactly as in the repro it fails only on that file (no license header, one comment line too long);
  • earlier, by the hunt: the fault-injection sweep with the fix leaked in 0 of 480 failed sorts (stock 32-39 of 320 with pools).

Not measured: performance.

B. Each job deletes its own output when it sees the abort flag

Set the flag in the catch blocks as in A, and let every job check it again after writing its file and delete that file, also when the job fails because the cleanup removed its inputs.

Trade-offs

sort() throws at once, but the files disappear only when the jobs finish, sort() gives the caller no signal when that is, and the getCreatedFiles() copy race stays for a separate TrackingDirectoryWrapper fix (compare #13142). Not tried.

C. Cancel the queued futures and wait only for running jobs

Future.cancel(false) on each queued job, then wait for the running ones before the cleanup.

Trade-offs

A cancelled SortPartitionTask never runs its finally, so its permit (taken in readPartition, released only by the job) would be lost and a later sort() on the same sorter could block, unless sort() releases it itself (the same permit accounting as LFV-384). Relies on the executor's futures honouring cancel. Not tried.

D. Deprecate or remove the executor option

No in-tree caller has used the 8-argument constructor with an executor since 9.9.0, and the class is @lucene.internal.

Trade-offs

Simplest for main, but it leaves released versions as they are for the callers that use it, and it is an API decision for the maintainers.

Downstream note

A leftover partition file holds a sorted copy of records the failed sort had already read, so an application that deletes its sort input for data-retention reasons can keep a copy after such a failure.

How it was found

TLA+ model checking (TLC) of a protocol model of sort(): the invariant "after a failed sort, once every job has finished, no temp file remains" fails for every pool size of 1 or more and holds without an executor. A fault-injection harness then showed it on the real jar, and the repro was cut to one deterministic test.

The method in full287 words

Method: formal (TLA+ model checking, the tool's own counterexample). In the hunt's offline-sorter hunt (2026-10-26, main ea57360912ed), the hunting AI tool wrote a protocol model of sort(): the main thread, the partitionsInRAM semaphore, a FIFO executor with W workers, partition and merge jobs, the merge cascade, the final merge and the failure cleanup, with one injected failure (an I/O error, corruption or an interrupt) at any step. Record contents are not modelled.

  • With no failure, TLC found no deadlock, every partition reaches the result exactly once, and only the result file remains.
  • With one failure, the invariant FailureClean ("after a failed sort and once every job has finished, no temp file remains") is violated in all 12 configurations with 1 or 2 workers (shortest trace 9 states: partition 1 queued, the main thread fails, the cleanup deletes nothing, the queued job then creates its file) and holds in all 6 with 0 workers, the same-thread executor.
  • The AI tool wrote the model and chose the invariant, the property the code's own comment states; TLC then found the failing schedule among every fault position and job schedule it explored, so the scenario was not hand-picked.

A fault-injection harness on the stock jar (one IOException per run, injected into the Directory or one of its inputs or outputs at each of the first 40 such operations and at 40 randomly chosen later ones; plain thread pools, no gating) confirmed it: 32 and 39 of 320 failed sorts leaked with 1- or 2-thread pools, 0 of 160 without an executor, 0 of 480 with the fix. The hunt's two-test JUnit followed; on 2026-10-29 it was replaced with the one deterministic test, because the interrupt test was not deterministic.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26: GitHub issues and PRs incl. the JIRA archive, the mailing lists, downstream projects, the web, and the changed files of all 411 open PRs). #8843 added the executor; open PR #13142 touches TrackingDirectoryWrapper but would not fix this.

Searches4 searches

Closest items, none a report of this bug:

  • #8843 (LUCENE-7792, closed 2017): added the executor; its 14 comments cover the SameThreadExecutorService design and the reThrow idiom only.
  • #7887 (LUCENE-6829, 2015) and LUCENE-4209 (2012): set the cleanup intent before the executor existed; precedent.
  • #7871 (LUCENE-6813, 2015): thread safety before the executor; a different root cause.
  • #13142 (open, stale since 2024-03): switches TrackingDirectoryWrapper to a concurrent set; it would make the getCreatedFiles() copy safe against concurrent adds, not stop the leak. Reviewers asked to revert that part because rename() needs an atomic add and remove.
  • #14913 (merged 2025-07): rewrote the cleanup into catch blocks with the same behaviour.

Sibling, a separate bug: LFV-384, from the same hunt: when the executor rejects a partition job, its permit is lost and a later sort() on the same sorter can wait forever. Different defect and fix site.

Limits: 4 planned web queries were not run; GitHub search does not index PR review comments unless the PR otherwise matches; private lists cannot be searched; a keyword search cannot find a symptom-only report.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits2026-10-26three scripted prior-report searches (155 queries in all, with downstream, list, JIRA and web queries); every issue and PR that mentions OfflineSorter (32) or TrackingDirectoryWrapper (31); the JIRA text search for OfflineSorter (30)no report of this bug; closest #8843, #7887, #7871, LUCENE-4209
Open PRs2026-10-26the changed files of all 411 open PRsnone touches OfflineSorter.java; #13142 touches TrackingDirectoryWrapper.java (would not fix it)
Mailing lists2026-10-26every lists.apache.org mail that mentions OfflineSorter (dev 233, java-user 1, issues 12)no report
Downstream projects and the web2026-10-26issue and code search in Elasticsearch, OpenSearch, Solr and Lucene.NET; 11 web queries ran, 4 planned ones were not runno report; no downstream code uses OfflineSorter

History

Introduced in 6.6.0 by LUCENE-7792 (db92a9efc2ee, 2017-04-26), which moved temp-file creation into executor jobs and kept the old one-shot cleanup. Every release from 6.6.0 to 10.6.0 and main is affected (executed on each release line); not fixed upstream.

Versions tested5 rows

Introduced by LUCENE-7792 "add optional concurrency to OfflineSorter" (now #8843), committed by Mike McCandless (GitHub mikemccand) on 2017-04-26 as db92a9efc2ee on master and bb8dd7a9f98d on branch_6x; CHANGES credits Dawid Weiss and Mike McCandless. JIRA patch era, no GitHub PR. The cleanup it kept dates from LUCENE-6829 (c855dd5a33f, 2015), when every temp file was created on the calling thread. #14913 (27d1272984f, 2025, main only) turned the finally (success == false) block into catch blocks with the same behaviour. The last change to OfflineSorter.java is a8888cade78 (2025-10-10, a test helper); upstream main 2f424fd428da (2026-10-30) has the same code.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedone-test repro fails with assertions on and off (standalone and gradle); passes with the fix
10.6.0 affectedone-test repro fails with assertions on and off (release jars, SHA-1 checked)
10.5.2, 9.12.3 affectedone-test repro fails (assertions on); the earlier two-test repro fails too
6.6.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 10.0.0, main ea57360912ed first affected 6.6.0the hunt's two-test repro fails (truncated input, interrupt); the exec-free control passes
6.5.1 no executor optionthe 8-argument constructor does not exist (compile error); the exec-free control passes

Independent checks

Confirmed with high confidence by an independent reproducer, a devil's-advocate verifier and an adjudication, all from executed runs, and by a cross-model root-cause review (reading only). A credibility audit confirmed it, and the repro was later cut to one deterministic test.

Checks and verdicts288 words

Everyone agrees on the root cause: the failure cleanup runs before the submitted work is finished, at both submission sites. The open points are judgement calls for a maintainer: whether to fix the cleanup or deprecate the executor option, and whether waiting on a caller-owned executor during a failure is acceptable.

Each independent check and its verdict
CheckVerdictWhat it found
finder (TLA+ model checking and fault-injection harness) candidateFailureClean violated for 1-2 workers in all 12 configurations, holds for 0 workers; the harness leaked in 32 and 39 of 320 failed sorts with pools, 0 of 160 without.
cross-model review (OpenAI GPT-5.6, intended lens) real, medium (reading only)Rated the works-as-designed case moderate and rejected it: no such limit is documented and the interrupt path is handled explicitly. The refute lens produced no verdict.
Independent reproducer realIts own test with different triggers (an injected read error, an interrupt in Semaphore.acquire) fails on main, 10.5.2 and 9.12.3 with assertions on and off; both controls pass.
Root-cause review (OpenAI GPT-5.6) real, mediumOne root cause at both submission sites; a separate timeline test fails on main; the fix makes the hunt's test pass 20 of 20. The reviewer read only; a separate run ran the tests.
Devil's advocate real, low impactNo intended, documented, misuse or unreachable argument survived; re-ran the repro on main and 10.5.2.
adjudication confirmed, high, minorRe-ran the independent reproduction's test with a new seed and the hunt's test; both fail for the stated reason.
credibility audit, main refresh confirmed; still reproducesThe audit re-ran the repro with a new seed; main a9779ef3c193 still fails; the repro was later cut to the one deterministic test.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,459 words

Finding record

LFV-357, snapshot of 2026-11-06

Registry title
OfflineSorter.sort with an ExecutorService runs its failure cleanup before the partition-sort and merge tasks it already submitted have finished, so each task still queued or running leaves a temp file (prefix_sort_N.tmp) behind that nothing deletes (released since 6.6.0)
Area
util / OfflineSorter concurrent sort (temp-file cleanup on failure)
Priority
Low, rank 1104: OfflineSorter leaves stray temp files behind, a minor resource leak that happens only after a failed or interrupted run with an opt-in executor, the same exception-path leak profile as LFV-309.
Impact class
minor
Impact note
Only applications that build OfflineSorter (tagged @lucene.experimental and @lucene.internal) with the 8-argument constructor and their own non-null ExecutorService (opt-in since 6.6.0; no in-tree caller on main and none found on the default branches of Elasticsearch, OpenSearch or Solr) and whose sort() fails (an I/O error, a truncated or corrupt input file, or an interrupt of the sorting thread) while a submitted task is still queued or running: each such task writes one temp file after the cleanup has run, which nothing deletes, and keeps using CPU, its partition buffer and I/O after the caller has seen the exception; bounded per failed sort (at most maxPartitionsInRAM partition tasks plus merges already submitted), no wrong result, no hang, no index file touched; ordinary scale, every release 6.6.0 to 10.6.0 and main.
Summary
You give OfflineSorter your own ExecutorService so it sorts partitions in parallel, and when sort() fails (an I/O error, a truncated input file, or an interrupt of the sorting thread) you expect it to delete every temp file it created, as it does without an executor and as its own code comment says ('So we can remove any partially written temp files on exception'). Instead sort() deletes only the temp files that exist at that moment and rethrows, without waiting for or cancelling the partition-sort and merge tasks it already handed to the executor. Those tasks then run and each writes a new prefix_sort_N.tmp file that nothing ever deletes (executed: the directory holds no sorter file right after sort() throws, and foo_sort_1.tmp once the queued task has run; with exec == null the same failure leaves nothing).
Introduced
db92a9efc2ee (LUCENE-7792 'add optional concurrency to OfflineSorter', by GitHub mikemccand, CHANGES credit dweiss and mikemccand, master; branch_6x backport bb8dd7a9f98d; committed from JIRA patches, no GitHub PR; the issue is now #8843) [read: it started submitting SortPartitionTask and MergePartitionsTask to the caller's executor but kept the existing one-shot TrackingDirectoryWrapper cleanup (from LUCENE-6829, c855dd5a33f, 2015), which deletes getCreatedFiles() once and does not wait for the futures; exec: 6.5.1 has no executor constructor (compile error) and its exec-free 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 [exec: compile error])
Affected versions
every release that has the ExecutorService constructor, 6.6.0 through 10.6.0, plus main ea57360912ed (not fixed), and only when a non-null executor is passed. Executed with the finder repro (2 of 2 tests fail unless noted; exec == null controls pass on 6.5.1, 6.6.0 and main): 6.6.0 (-ea and -da), 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3 (-da 2 of 2; -ea 1 of 2, the interrupt test did not leak in that run), 10.0.0, 10.5.2 (-ea 2 of 2; -da 1 of 2, same caveat) and main (-ea and -da) [exec]; over --iters 50: 100 of 100 executions fail on 10.5.2 and 9.12.3 (-ea and -da), 99 of 100 (-ea) and 100 of 100 (-da) on main, and 40 of 40 in 20 separate JVM launches on 10.5.2 -da [exec]; the independent reproduction's own test fails on main, 10.5.2 and 9.12.3 (-ea and -da) [exec]; the one-test minimal repro fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da (2026-11-01) [exec]; the releases between the executed ones are inferred from tag ancestry and an unchanged sort() body (9.x and 10.x differ only in formatting and an IOContext argument; main turned the finally block into catch blocks with the same semantics, #14913) [read]; 8.8.2 to 8.11.3, cut from the separate lucene-solr repository, inferred
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
formal: TLA+/TLC
Upstream and prior reports
none found (2026-10-26: three scripted prior-report searches with 155 queries (45 + 54 + 56; GitHub issues, PRs and commits, downstream repositories, the dev, java-user, issues and commits lists, the JIRA archive, and 16 web queries; 4 more planned web queries were not run); a full enumeration of every apache/lucene issue and PR that mentions OfflineSorter (32) or TrackingDirectoryWrapper (31); the JIRA text search for OfflineSorter (30 issues); every lists.apache.org mail that mentions OfflineSorter (dev 233, java-user 1, issues 12); downstream issue and code search (Elasticsearch, OpenSearch, Solr, Lucene.NET); the changed-file lists of all 411 open apache/lucene PRs; and the commit history of both files; 20 hits read, none the same bug). Closest items, none this bug: #8843 (LUCENE-7792, added the executor; its 14 comments discuss only SameThreadExecutorService and the reThrow idiom, nothing about failures with tasks in flight); #7887 (LUCENE-6829, added the TrackingDirectoryWrapper cleanup on exception, before the executor existed); #7871 (LUCENE-6813, pre-executor thread safety and a file-handle leak, other root cause); LUCENE-4209 (an earlier temp-file leak in the suggester's sorter, fixed 2012, precedent only); open PR #13142 (switches TrackingDirectoryWrapper to a concurrent set; it would make the getCreatedFiles() copy safe against concurrent adds but does not wait for or cancel the tasks, so it would not fix this; reviewers asked to revert that part); #14913 (merged 2025-07-14, rewrote the cleanup into catch blocks with the same semantics). No open PR touches OfflineSorter.java; the last change to the file is a8888cade78 (2025-10-10, a test helper)
Lucene commit tested
main 645d4fe14a20 (the one-test repro with assertions on and off, standalone and through gradle; the fix in a scratch worktree: the repro, TestOfflineSorter, the suggest module's tests and :lucene:core:check); the 10.6.0 release jars (assertions on and off) and 10.5.2 and 9.12.3 (assertions on) with the one-test repro; the hunt's earlier two-test repro on 6.6.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main ea57360912ed. Upstream main 2f424fd428da (2026-10-30) has the same OfflineSorter.java and TrackingDirectoryWrapper.java (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:49-50 (@lucene.experimental, @lucene.internal), :225-229 (constructor javadoc: optional ExecutorService, RAM bound maxPartitionsInRAM * ramBufferSize), :286 (sort javadoc), :295-296 ('So we can remove any partially written temp files on exception' + TrackingDirectoryWrapper), :311 (SortPartitionTask submitted), :353-356 ('removing all intermediate files ourselves unless there is an exception'), :363-369 (catch blocks delete getCreatedFiles() and rethrow), :398 (MergePartitionsTask submitted), :432 (permit acquired in readPartition), :629-657 (SortPartitionTask.call creates its temp file at :631 only when it runs; permit released at :654), :660-662 (getPartition waits on the future, success path only), :700 (MergePartitionsTask creates its output)
  • lucene/core/src/java/org/apache/lucene/store/TrackingDirectoryWrapper.java:27 (synchronizedSet), :47-50 (createTempOutput records the name), :70-73 (getCreatedFiles returns a copy made without holding the set's lock)
  • lucene/core/src/test/org/apache/lucene/util/TestOfflineSorter.java:78 (randomExecutorServiceOrNull, used only by tests that sort successfully), :331, :366, :424, :472 (testBitFlipped* failure tests construct the sorter with exec == null)
  • lucene/CHANGES.txt:8279-8280 (LUCENE-7792 'OfflineSorter can now run concurrently if you pass it an optional ExecutorService'), :15272-15275 (LUCENE-4209, temp files left behind fixed as bugs)
  • a Lucene clone tag releases/lucene/9.8.0: lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:669-692 (passes its ForkJoinPool, when one is set, to the executor constructor; removed in 9.9.0 by #12712) [read]
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:240-250, :286-370 (:295-296, :309-311, :350, :363-369), :394-398, :430-433, :629-657, :660-670, :683-700 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/TrackingDirectoryWrapper.java:27, :46-52, :70-73 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/SameThreadExecutorService.java:34-37; lucene/core/src/java/org/apache/lucene/util/IOUtils.java:247-268 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:49-50, :225-229, :432, :654 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/SortingStrategy.java:113; lucene/suggest/src/java/org/apache/lucene/search/suggest/SortedInputIterator.java:177; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:397; lucene/suggest/src/java/org/apache/lucene/search/suggest/fst/FSTCompletionLookup.java:165 (at 645d4fe14a20)
  • a Lucene clone releases/lucene/9.8.0: lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:671-692; git log releases/lucene/9.8.0..releases/lucene/9.9.0 (1cb1a14cc84, #12712)
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:228-229, :250, :432, :654, :698-707, :749-753 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:226-227, :286, :295, :353-354, :363-369 (at 645d4fe14a20)
  • lucene/CHANGES.txt:8312-8313 (LUCENE-7792), :15305-15308 (LUCENE-4209) (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/TestOfflineSorter.java:78-91, :96, :120, :145, :356, :414, :460, :528, :551 (at 645d4fe14a20)
  • a Lucene clone releases/lucene-solr/7.7.3: lucene/core/src/test/org/apache/lucene/util/bkd/TestBKD.java:381, :413; lucene/core/src/java/org/apache/lucene/util/bkd/BKDWriter.java:909
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/OfflineSorter.java:350, :432, :652-655, :662 (at 645d4fe14a20); lucene/core/src/test/org/apache/lucene/util/TestOfflineSorter.java:293-308 (at 645d4fe14a20)
  • gradle/libs.versions.toml minJava (25 at 645d4fe14a20; 21 at releases/lucene/10.6.0)
History
  • a Lucene clone: git diff releases/lucene/10.6.0 645d4fe14a20 -- OfflineSorter.java (the finally block in 10.6.0)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and 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.