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.
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.- Since 6.6.0 you can pass an
ExecutorServiceto the 8-argument constructor: partition sorts and merges then run as background jobs, at mostmaxPartitionsInRAMpartitions in memory at a time. Without one, every job runs on the calling thread. - 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".
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.- Build an
OfflineSorterwith your ownExecutorService(the 8-argument constructor,maxPartitionsInRAM2) 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. - Before that job runs, reading partition 2 reaches the cut-off record and throws
EOFException. You expectsort()to throw it and leave only the input file, as it does without an executor. - 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. - 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.
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.
- Partition: as many input records as fit in the RAM buffer; reading one takes a
partitionsInRAMpermit (OfflineSorter.java:430-433). - Job: a
SortPartitionTask(sorts one partition into a temp file) or aMergePartitionsTask(merges up tomaxTempFilestemp files), submitted withexec.submit(...). TrackingDirectoryWrapper: aDirectorywrapper that records the name of every file created through it;getCreatedFiles()returns a copy of those names.
sort()starts with// So we can remove any partially written temp files on exception:and wraps the directory in aTrackingDirectoryWrapper(:295-296).- With
exec != nullthe constructor keeps the caller's executor (:240-245); otherwise it uses aSameThreadExecutorService, whoseexecutejust runs the job (:246-248;SameThreadExecutorService.java:34-37). - Each partition becomes a job,
Callable<Partition> job = new SortPartitionTask(trackingDir, part);(:309), submitted withsegments.add(exec.submit(job));(:311); merges are submitted the same way (:394-398). - A job creates its temp file only when it runs:
dir.createTempOutput(tempFileNamePrefix, "sort", ...)inSortPartitionTask.call(:630-631) and inMergePartitionsTask.call(:698-700). - On failure both catch blocks (
:363-369) callIOUtils.deleteFilesSuppressingExceptions(..., trackingDir, trackingDir.getCreatedFiles())and rethrow. That set is a copy taken at that moment (TrackingDirectoryWrapper.java:70-73), and nothing touches the futures insegments: nocancel, noget. - 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.
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.
OfflineSorteris tagged@lucene.experimentaland@lucene.internal. On main, hunspellSortingStrategyand the suggesters (SortedInputIterator,AnalyzingSuggester,FSTCompletionLookup) use the 2- or 3-argument constructors. In release 9.8.0BPIndexReorderer(lucene/misc) passed itsForkJoinPool(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 ofOfflineSorter. - What happens. Per failed sort, at most one leftover
<prefix>_sort_N.tmpfor each job that had not yet created its file: at mostmaxPartitionsInRAMpartition jobs plus any merge jobs already submitted. There can be fewer, because a merge that runs later deletes the partition files it consumes: withmaxTempFiles2, 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 documentedmaxPartitionsInRAM * ramBufferSizebound is per sorter and still holds: a latersort()on the same sorter waits for those permits, and can wait forever if the executor never runs the orphaned jobs (for example aftershutdownNow()dropped them; code reading). - What does not happen. No wrong or partial result (
sort()throws), the failedsort()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.
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.
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;
}
}
}
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()throwsEOFExceptionand 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 wrotefoo_sort_1.tmpafter the cleanup; the same with assertions off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on the 10.6.0, 10.5.2 and 9.12.3 releases |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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]- 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". WhenBKDWriterused this class,TestBKD.testWithExceptionschecked "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-up25f1dd2f9b2"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/2use the 2-argument constructor andtestBitFlippedOnPartition1/2passnull(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 (comparetestBitFlippedOnInput2) 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.
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 mostmaxPartitionsInRAMpartition 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 withshutdownNow()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, asort()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 (sortInfois shared the same way;testThreadSafetyuses one sorter per thread). Passing the call's list and flag to the jobs would drop that requirement.submittedkeeps 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: seedDEADBEEF15 tests, 0 failures (2 nightly skipped); seed5EED1234with-Ptests.iters=575 tests, 0 failures; with-Ptests.nightly=true15 tests, 0 failures, 0 skipped (incl. the executor-usingtestLargerRandomandtestFixedLengthHeap);:lucene:suggest:test: 31 classes, 236 tests, 0 failures, 7 skipped;:lucene:core:check -x testwith 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.
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.
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.
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.
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.
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.
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
SameThreadExecutorServicedesign and thereThrowidiom 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
TrackingDirectoryWrapperto a concurrent set; it would make thegetCreatedFiles()copy safe against concurrent adds, not stop the leak. Reviewers asked to revert that part becauserename()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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits | 2026-10-26 | three 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 PRs | 2026-10-26 | the changed files of all 411 open PRs | none touches OfflineSorter.java; #13142 touches TrackingDirectoryWrapper.java (would not fix it) |
| Mailing lists | 2026-10-26 | every lists.apache.org mail that mentions OfflineSorter (dev 233, java-user 1, issues 12) | no report |
| Downstream projects and the web | 2026-10-26 | issue and code search in Elasticsearch, OpenSearch, Solr and Lucene.NET; 11 web queries ran, 4 planned ones were not run | no report; no downstream code uses OfflineSorter |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | one-test repro fails with assertions on and off (standalone and gradle); passes with the fix |
| 10.6.0 | affected | one-test repro fails with assertions on and off (release jars, SHA-1 checked) |
| 10.5.2, 9.12.3 | affected | one-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.0 | the hunt's two-test repro fails (truncated input, interrupt); the exec-free control passes |
| 6.5.1 | no executor option | the 8-argument constructor does not exist (compile error); the exec-free control passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (TLA+ model checking and fault-injection harness) | candidate | FailureClean 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 | real | Its 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, medium | One 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 impact | No intended, documented, misuse or unreachable argument survived; re-ran the repro on main and 10.5.2. |
| adjudication | confirmed, high, minor | Re-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 reproduces | The audit re-ran the repro with a new seed; main a9779ef3c193 still fails; the repro was later cut to the one deterministic test. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 454 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,459 words
- 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 mainea57360912ed. Upstream main2f424fd428da(2026-10-30) has the sameOfflineSorter.javaandTrackingDirectoryWrapper.java(read).
- 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)
- TestOfflineSorterFailureCleanup.java (JUnit, one test):
repro/LFV-357/src/org/apache/lucene/util/TestOfflineSorterFailureCleanup.java(99 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-357/bug.png(454 KB)
