All findings · Lucene finding · LFV-331 · fsdirectory-pending-retry-deletes-recreated-file

FSDirectory: a pending-delete retry on one thread can delete a file that another thread has just re-created under the same name

On a file system that refuses to delete open files (in practice Windows), FSDirectory retries those deletes later from a copy of its pending list, and that retry can delete a file another thread has just re-created under the same name. Affects 6.0.0 to 10.6.0; IndexWriter never re-uses pending names.

Summary

Priority
Low, rank 904An FSDirectory pending-delete retry can delete a re-created file, but only on delete-refusing file systems, in a narrow race, with no in-tree caller.
Impact
data corruption
Since
6.0.0 (LUCENE-6835, 2016)introduced 2016-02-06 · over 10 years
Affected
6.0.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
TLA+ model of FSDirectory's pending deletes, checked with TLC (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

FSDirectory retries a refused delete later, from a later mutating call on any thread, and that retry deletes by name from a copy of the pending list. Re-creating a pending name takes it off the list without waiting for a retry that is already running, so that retry deletes the new file.

Background: pending deletes in FSDirectory

  1. On Windows a file that is still open (for example memory-mapped by a reader, or held by a virus scanner) cannot be deleted. When deleteFile fails this way, FSDirectory remembers the name as a pending delete: listAll() hides it and openInput refuses it.
  2. FSDirectory retries pending deletes by itself. Every sync, syncMetaData, deleteFile, createOutput, createTempOutput and rename call adds one to a shared counter; the call at which the counter reaches the current number of pending names resets it and runs deletePendingFiles() on its own thread (with a fixed set of N names, every N-th call).
  3. A pending name may be used again: createOutput or rename onto it "brings it back to life" by taking it off the list, deleting the old file and creating the new one.
Diagram of the minimal repro: files x and y are deleted while open, so FSDirectory keeps both as pending deletes; thread A's syncMetaData() retries the pending deletes from a copy {x, y}; meanwhile thread B's createOutput("x") takes x off the list, deletes the old x and creates and closes a new x; A's retry then deletes the new x, so listAll() returns [] instead of [x]. With the fix, B waits until the retry has finished and x survives.
The minimal repro on one timeline: thread A's retry copies the pending list {x, y}, thread B re-creates x, then the retry deletes the new x. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. On a file system that refuses to delete open files (the test uses the test framework's WindowsFS), create files x and y, open a reader on each and call deleteFile on both. The deletes are refused, so FSDirectory puts both names on its pending-delete list and hides them from listAll().
  2. Close the reader of x. Thread A makes an unrelated call, syncMetaData(). FSDirectory uses it to retry the pending deletes, working from a copy of the list: {x, y}.
  3. While A works through its copy, thread B calls createOutput("x"): it takes x off the list, deletes the old x, creates the new x and closes it. Every call succeeds.
  4. You expect listAll() to return [x]: the new x was created, closed and never deleted.
  5. Instead A's retry reaches x in its old copy and deletes the new file: listAll() returns [] (a diagnostic run of the same steps also shows openInput("x") throwing NoSuchFileException). Renaming another file onto x loses it the same way.

Who is affected

Code that uses an FSDirectory (MMapDirectory, NIOFSDirectory; default settings) directly on a file system that refuses deletes of open files, in practice Windows, and re-creates or renames onto a name it deleted while still open, while another thread uses the same directory. Each hit silently loses one file. IndexWriter never re-uses pending names.

Mechanism, terms and numbers911 words

On a file system that refuses to delete a file while it is open (in practice Windows, with a memory-mapped file or a virus scanner holding a handle), FSDirectory.deleteFile records the name as a pending delete, hides it from listAll(), and retries the delete from a later mutating call on any thread (the call at which a shared call counter reaches the number of pending names). The retry, deletePendingFiles(), is synchronized and deletes by name every entry of a copy of the pending set. createOutput and rename may re-use a pending name ("If this file was pending delete, we are now bringing it back to life"): they take the name off the set, delete the old file and create or move the new one, without that monitor. So a retry that copied the set before the revive deletes the new file afterwards, although every call that wrote it succeeded: in the one-test repro listAll() returns [] instead of [x], and a diagnostic run of the same steps shows openInput("x") throwing NoSuchFileException. The other thread's call is unrelated (syncMetaData()), so no per-name application lock avoids it. It has been there since pending deletes arrived in 6.0.0 (LUCENE-6835, 2016) and fails on 10.6.0 and main. IndexWriter never re-uses a pending name, so the exposure is application or downstream code that uses the Directory directly. Making the revive take the same monitor (about ten lines) fixes it; the repro and all 8,898 core tests pass with it. Everything ran under the test framework's WindowsFS emulation on Linux, not on real Windows.

The code (main 645d4fe14a20, FSDirectory.java)

// FSDirectory.java:207-216  createOutput
public IndexOutput createOutput(String name, IOContext context) throws IOException {
  ensureOpen();
  maybeDeletePendingFiles();                      // :209  may run a retry (synchronized) itself
  // If this file was pending delete, we are now bringing it back to life:
  if (pendingDeletes.remove(name)) {              // :211  no monitor held
    privateDeleteFile(name, true); // try again to delete it - this is the best effort
    pendingDeletes.remove(name); // watch out - if the delete fails it put
  }
  return new FSIndexOutput(name);                 // :215  WRITE, CREATE_NEW
}

// FSDirectory.java:312-323
public synchronized void deletePendingFiles() throws IOException {
  if (pendingDeletes.isEmpty() == false) {
    // Clone the set since we mutate it in privateDeleteFile:
    for (String name : new HashSet<>(pendingDeletes)) {   // :319  a copy
      privateDeleteFile(name, true);                     // Files.delete by name (:339)
    }
  }
}

Step by step

  1. A name becomes pending. privateDeleteFile (:337-368) calls Files.delete; any IOException other than "no such file" puts the name in pendingDeletes (:355-366: "On windows, a file delete can fail because there's still an open file handle against it"). listAll() skips pending names (:172-195) and openInput refuses them (ensureCanRead, :236-241).
  2. Any thread runs the retry. createOutput (:209), createTempOutput (:222), sync (:250), rename (:260), syncMetaData (:273) and deleteFile (:305) call maybeDeletePendingFiles (:325-335), which counts the call in a shared AtomicInteger (:99) and, when the count reaches the current pendingDeletes.size(), resets it and runs deletePendingFiles() (:329-332). With a fixed set of N pending names that is every N-th call; names added or removed and concurrent calls shift it.
  3. The retry works on a copy. It holds the FSDirectory monitor and deletes, by name, every entry of new HashSet<>(pendingDeletes) (:319), without checking whether a name is still pending.
  4. The revive does not take the monitor. createOutput (:211-214) and rename (:261-264) remove the name, retry the old file's delete, then create (:215) or move (:265) the new file. Nothing waits for a retry that is already running.
  5. The repro's interleaving. deleteFile("x") and deleteFile("y") are refused; x's reader closes. Thread A's syncMetaData() reaches the counter threshold (2 calls for 2 pending names) and retries from the copy {x, y}. At the retry's first delete a test hook starts thread B and waits until B has finished or is blocked. B's createOutput("x") (its own counter is 1 of 2, so it runs no retry) removes x from the set, deletes the old x, creates the new one and closes it. A resumes and Files.delete(x) removes the new file; y is refused again. The hook only fixes the order of the two threads; it changes no directory or file state.
  6. Without any hook. A stress test with ordinary scheduling (64 held files, 2,000 re-creates while another thread keeps calling sync) lost 199 to 321 of 2,000 re-created files on main in three runs, and 0 with the fix. Its op rate is deliberately aggressive; these are not field rates.
  7. Secondary symptom, emulation only. If A's retry reaches the name after B deleted the old file and before B created the new one, Files.delete throws NoSuchFileException, which privateDeleteFile rethrows when not on Windows (:341-354), so A's unrelated call fails (406 times in one stress run). On real Windows that branch swallows this exception (by reading, not run on Windows), so there the symptom is the lost file alone.
scenario (both names pending, x's reader closed)listAll() after both threadsexpected
B re-creates x while A's retry runs (the repro)[] (a diagnostic run: x gone from disk)[x]
B renames another file onto x while A's retry runsx missingx present
A's retry runs after B's re-create (control)[x][x]
the repro with the fix[x][x]
Exposure and likelihood320 words
  • Who is hit. Applications or downstream code that use an FSDirectory (MMapDirectory, the FSDirectory.open default, or NIOFSDirectory) directly, with default settings, on a file system that refuses to delete open files, and that re-create (createOutput) or rename onto a name they deleted while it was still open, while another thread makes any mutating call on the same directory. No scale is needed.
  • What goes wrong. A file whose createOutput (or rename) and close succeeded silently disappears. Nothing fails at write time; the loss shows up later as NoSuchFileException or as a file missing from listAll().
  • Which file systems. In practice Windows, where a memory-mapped file or an outside handle (a virus scanner, a backup tool) makes the delete fail; the code comments also name "a Linux box with a CIFS mount". By reading, not run on Windows: Java opens files on Windows with delete sharing by default, so NIOFSDirectory's own readers probably do not block a delete there, while memory-mapped files do. The repro's NIOFSDirectory on WindowsFS emulates a delete-refusing file system.
  • What does not go wrong. No in-tree caller is known to re-use a pending name, so IndexWriter-managed index files are not known to be exposed: IndexFileDeleter counts getPendingDeletions() before it inflates generations, so file generations move past pending names, and the NRT ReplicaNode refuses to start while deletions are pending. The replicator copies into temp files (createTempOutput skips pending names) and then renames each onto its final name (CopyJob.finish), which is this bug's rename path; those final names are new segment file names, so it is not known to rename onto a pending name either.
  • How likely: rare. The window is narrow (the stress test lost 0 of 2,000 when the name was pending only briefly) and nothing has been reported in about ten years. Impact class data corruption (a successfully written file is deleted); priority low.

Minimal repro

One JUnit test on the public API plus the test framework's WindowsFS (refuses deletes of open files) and a delete hook that starts thread B at the retry's first delete and waits until B has finished or is blocked. Two threads, because the bug is a race; the hook fixes their order, so the test is deterministic.

TestFSDirectoryPendingDeleteRetry.java (one test)77 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.store;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.lucene.tests.mockfile.FilterFileSystemProvider;
import org.apache.lucene.tests.mockfile.WindowsFS;
import org.apache.lucene.tests.util.LuceneTestCase;

@LuceneTestCase.SuppressFileSystems("*")
public class TestFSDirectoryPendingDeleteRetry extends LuceneTestCase {

  public void testRetryKeepsFileRecreatedByAnotherThread() throws Exception {
    Path tmp = createTempDir();
    Path windows = new WindowsFS(tmp.getFileSystem()).wrapPath(tmp);
    AtomicReference<Thread> startOnNextDelete = new AtomicReference<>();
    Path root =
        new FilterFileSystemProvider("hook://", windows.getFileSystem()) {
          @Override
          public void delete(Path path) throws IOException {
            Thread t = startOnNextDelete.getAndSet(null);
            if (t != null) {
              t.start();
              while (t.getState() != Thread.State.TERMINATED
                  && t.getState() != Thread.State.BLOCKED) {
                Thread.onSpinWait();
              }
            }
            super.delete(path);
          }
        }.wrapPath(windows);
    try (FSDirectory dir = new NIOFSDirectory(root)) {
      dir.createOutput("x", IOContext.DEFAULT).close();
      dir.createOutput("y", IOContext.DEFAULT).close();
      IndexInput inX = dir.openInput("x", IOContext.DEFAULT);
      IndexInput inY = dir.openInput("y", IOContext.DEFAULT);
      dir.deleteFile("x");
      dir.deleteFile("y");
      inX.close();
      Thread recreateX =
          new Thread(
              () -> {
                try {
                  dir.createOutput("x", IOContext.DEFAULT).close();
                } catch (IOException e) {
                  throw new UncheckedIOException(e);
                }
              });
      startOnNextDelete.set(recreateX);
      dir.syncMetaData();
      recreateX.join();
      // expected: x, re-created by the other thread, still exists; actual: the retry deleted it
      assertTrue(
          "expected x in listAll() but was " + Arrays.toString(dir.listAll()),
          Arrays.asList(dir.listAll()).contains("x"));
      inY.close();
    }
  }
}
Command
# $REPRO holds TestFSDirectoryPendingDeleteRetry.java; $LUCENE is a Lucene checkout
cp $REPRO/TestFSDirectoryPendingDeleteRetry.java $LUCENE/lucene/core/src/test/org/apache/lucene/store/
cd $LUCENE
./gradlew :lucene:core:test --tests TestFSDirectoryPendingDeleteRetry -Ptests.seed=DEADBEEF
Expected
listAll() returns [x]: the file thread B created and closed is still there.
Observed
listAll() returns [] (expected x in listAll() but was []): the retry deleted the new x.

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

More files, environment, full output313 words

Why two pending names: the retry runs when a call counter reaches the number of pending names. With x alone that threshold is 1, so B's own createOutput would run the retry itself and block on the monitor A holds; y raises it to 2 and keeps B's call (counter 1) below it. With a fix that serializes the revive, B is blocked when the hook lets the retry go on, so the test passes instead of hanging.

Deterministic: 10 of 10 failures with 10 iterations, also with one CPU; the seed only sets the test framework's randomization. The same failure through the repro harness on main (assertions on and off), 10.6.0 (on and off) and 10.5.2. The follow-up's history probe, with a rename scenario and a sequential control that keeps the file, loses the file on every release run from 6.0.0 to 10.5.2 (on 6.2.0-7.3.1 only through rename). Use this one test as the regression test too (its hook spins without a timeout; an upstream version may prefer a bounded wait that fails with a message). Everything ran under WindowsFS emulation on Linux, not on real Windows.

Environment

lucenemain 645d4fe14a20, 10.6.0 and 10.5.2: reproduced; 6.0.0 to 10.5.2: reproduced by the history probe (6.2.0-7.3.1 only through rename); 5.5.5: no pending deletes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (delete refusal emulated by the test framework's WindowsFS)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestFSDirectoryPendingDeleteRetry > testRetryKeepsFileRecreatedByAnotherThread FAILED
    java.lang.AssertionError: expected x in listAll() but was []
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:D230F9D95B40624C]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.store.TestFSDirectoryPendingDeleteRetry.testRetryKeepsFileRecreatedByAnotherThread(TestFSDirectoryPendingDeleteRetry.java:71)

# repro harness, the same message: main -ea and -da, 10.6.0 -ea and -da, 10.5.2 -ea (1 run, 1 failed each)
# diagnostic copy of the test (not the repro), main: x is not on disk, openInput("x") throws NoSuchFileException
# with fix A or fix B: the test passes (gradle; harness -ea and -da)

Why it looks unintended

  • Directory: a created file "must only be opened for input (openInput), or deleted (deleteFile)"; here only FSDirectory's own retry deletes the new x.
  • Re-using a pending name is supported: "If this file was pending delete, we are now bringing it back to life".
  • LUCENE-8318 (#9365) fixed this symptom for a single-threaded cause in 2018; the concurrent path was never covered.
The full argument236 words
  • Contract. Directory.java:35-40: "A file in a directory can be created (createOutput), appended to, then closed. ... Once a file is created it must only be opened for input (openInput), or deleted (deleteFile)." Directory.java:83-84: createOutput "Creates a new, empty file in the directory".
  • The revive is deliberate. FSDirectory.java:210: "If this file was pending delete, we are now bringing it back to life". Since LUCENE-8275 (#9322, 7.4.0) it even deletes the old file first so that the new one can be created.
  • Precedent. LUCENE-8318 (#9365, 2018) fixed exactly this symptom, "This causes the dest file to be deleted later by deletePendingFiles", for a single-threaded cause (a failed retry put the name back). Its test asserted that the renamed-onto file survives (testRenameWithPendingDeletes, removed together with SimpleFSDirectory in 2020).
  • The code is built for concurrent callers. The pending set is a concurrent set (FSDirectory.java:97), the call counter an AtomicInteger (:99), the retry is synchronized, and any thread's call may run it; #11619 (LUCENE-10583) shows retries running on merge threads in production. This is the implementation, not an API promise: the Directory javadoc does not say that concurrent mutation of one directory is safe, so a maintainer may still call this use unsupported.
  • History. None of the LUCENE-6835, LUCENE-8275 or LUCENE-8318 discussions mentions a re-create racing a retry on another thread; no "by design" or "won't fix" statement covers it.
The devil's-advocate check196 words

Strongest case for "intended": Lucene is write-once; IndexWriter never re-uses a pending name (LUCENE-8310 made generations move past them); the Directory javadoc promises nothing about concurrent use of one name; and the pending-delete layer is openly best effort ("this is hacky/lenient", FSDirectory.java:360; "this is the best effort", :212). So: do not re-create names on Windows.

Why it fails:

  1. The application does not use one name concurrently: thread A only calls syncMetaData() (or sync() of another file). The delete of x is FSDirectory's own retry, which no per-name application lock can avoid.
  2. "Best effort" describes the retried delete of the old file. Nothing accepts losing the new, successfully closed file, and LUCENE-8318 treated that loss as a bug.
  3. FSDirectory implements the revive on purpose. What IndexWriter does limits who is hit, not whether it is a bug.

Verdict: not intended, a defect with narrow reach. A likely objection is that names are not re-used and that pending deletes are rare outside Windows (a reviewer rated the single-threaded form of this pattern in a downstream FSDirectory subclass "Low severity since names aren't reused and this is Linux in practice", elastic/elasticsearch#155919); the points above answer it.

Suggested fix

Recommended A. Synchronized revive, entered only when the name is pending

Make the revive in createOutput and rename take the monitor that deletePendingFiles() holds, through a small synchronized helper entered only when the name is pending. A running retry then finishes before the name is revived. The repro passes, as do all 8,898 core tests; nothing changes while no delete is pending.

All options and trade-offs4 options ยท 939 words

Two code fixes were run; A is recommended, B is the hunt's simpler variant. Both make a retry that already copied the pending set finish before the name is revived, and neither adds a lock object: createOutput already enters this monitor whenever its own maybeDeletePendingFiles runs a retry (FSDirectory.java:329-332). Workaround for users today: do not re-create or rename onto a name you deleted while it might still be open; use a fresh name, as IndexWriter does, or serialize all calls on that directory with your own lock (not the Directory instance, as Directory.java:43-45 warns).

A. Synchronized revive, entered only when the name is pending (validated by the checks) (recommended)

Move the revive block of createOutput and rename into a private synchronized helper and call it only when pendingDeletes.contains(name). The unlocked check is safe for this race: a retry holds the monitor from its copy to the end of its loop, and during that loop a name in its copy leaves the set only through the retry itself (which is then done with it) or a revive (which now needs the monitor). With no pending deletes, the normal case on POSIX local file systems, createOutput and rename behave as today and still take no lock; the only change there is a contains lookup on the empty set instead of a remove.

--- a/lucene/core/src/java/org/apache/lucene/store/FSDirectory.java
+++ b/lucene/core/src/java/org/apache/lucene/store/FSDirectory.java
@@ -208,9 +208,8 @@ public abstract class FSDirectory extends BaseDirectory {
     ensureOpen();
     maybeDeletePendingFiles();
     // If this file was pending delete, we are now bringing it back to life:
-    if (pendingDeletes.remove(name)) {
-      privateDeleteFile(name, true); // try again to delete it - this is the best effort
-      pendingDeletes.remove(name); // watch out - if the delete fails it put
+    if (pendingDeletes.contains(name)) {
+      revivePendingDelete(name);
     }
     return new FSIndexOutput(name);
   }
@@ -258,9 +257,8 @@ public abstract class FSDirectory extends BaseDirectory {
           "file \"" + source + "\" is pending delete and cannot be moved");
     }
     maybeDeletePendingFiles();
-    if (pendingDeletes.remove(dest)) {
-      privateDeleteFile(dest, true); // try again to delete it - this is the best effort
-      pendingDeletes.remove(dest); // watch out if the delete fails, it's back in here
+    if (pendingDeletes.contains(dest)) {
+      revivePendingDelete(dest);
     }
     Files.move(directory.resolve(source), directory.resolve(dest), StandardCopyOption.ATOMIC_MOVE);
   }
@@ -322,6 +320,18 @@ public abstract class FSDirectory extends BaseDirectory {
     }
   }
 
+  /**
+   * Takes {@code name} out of the pending-delete set, retrying the delete of the old file first.
+   * Synchronized like {@link #deletePendingFiles()}, so that a concurrent retry loop that already
+   * copied the pending set cannot delete the new file created under this name afterwards.
+   */
+  private synchronized void revivePendingDelete(String name) throws IOException {
+    if (pendingDeletes.remove(name)) {
+      privateDeleteFile(name, true); // try again to delete it - this is the best effort
+      pendingDeletes.remove(name); // watch out - if the delete fails it put
+    }
+  }
+
   private void maybeDeletePendingFiles() throws IOException {
     if (pendingDeletes.isEmpty() == false) {
       // This is a silly heuristic to try to avoid O(N^2), where N = number of files pending

Trade-offs

A createOutput or rename of a pending name now waits for another thread's running retry loop (one delete attempt per pending file). An application that synchronizes on the Directory instance, which Directory.java:43-45 warns against since #11619 (LUCENE-10583, a deadlock against deletePendingFiles on Windows), can now also block such a revive; the existing retry path already has that exposure.

What the checks validated

  • Scratch worktree of main 645d4fe14a20 (removed afterwards), seed DEADBEEF: all of :lucene:core:test with A and the repro added, assertions on: 8,898 tests in 749 classes, 322 skipped, 0 failures.
  • The closest suites with A, -Ptests.asserts=false: org.apache.lucene.store.* (including the repro), TestIndexWriter, TestNRTReaderCleanup, TestIndexFileDeleter: 908 tests, 18 skipped, 0 failures.
  • Repro harness with A shadowed in: passes with assertions on and off and 10 of 10 iterations; the hook-free stress test loses 0 of 2,000 re-created files with no spurious NoSuchFileException.

B. The hunt's variant: always call the synchronized revive (validated by the checks)

The same helper, called unconditionally: revivePendingDelete(name); in createOutput and revivePendingDelete(dest); in rename.

-    if (pendingDeletes.remove(name)) {
-      privateDeleteFile(name, true); // try again to delete it - this is the best effort
-      pendingDeletes.remove(name); // watch out - if the delete fails it put
-    }
+    revivePendingDelete(name);

Trade-offs

Every createOutput and rename now enters the monitor, even when nothing is pending, and so waits for any concurrent retry or close(). Simpler to read than A; slightly more contention.

What the checks validated

  • Scratch worktree of main 645d4fe14a20 (removed afterwards), seed DEADBEEF: stock, the repro fails through gradle; with B it passes together with the closest suites (908 tests, 18 skipped, 0 failures) with assertions on and off, and all of :lucene:core:test (8,898 tests, 322 skipped, 0 failures, assertions on).
  • Earlier, by the hunt: the stress test lost 0 of 2,000 files; the history probe's createOutput, rename and control scenarios all keep the file; the hunt's latch test passes.

C. Skip names that are no longer pending in the retry loop (not a fix) (validated by the checks)

if (pendingDeletes.contains(name)) privateDeleteFile(name, true); inside deletePendingFiles.

Trade-offs

A check-then-act: a revive between the check and Files.delete still loses the file, so it only narrows the window. A diagnostic copy of it made the independent reproducer's latch tests pass, which shows the stale copy is the cause, not that the race is gone.

What the checks validated

Only as a diagnostic, by the independent reproducer.

D. Javadoc only

Document that a name whose delete is pending must not be re-used while other threads use the directory.

Trade-offs

Contradicts the explicit revive code and LUCENE-8318's precedent; not recommended.

Downstream note

Downstream code that deletes a file and then renames a temp file onto the same name through its FSDirectory (one search server's store recovery does this) could hit it on Windows if another thread used the same directory meanwhile; that overlap was not traced.

How it was found

While writing a TLA+ model of FSDirectory's pending-delete protocol, the finder suspected the race from the code; the TLC model checker then found a 10-state trace in which a created, never-deleted file disappears, and none with the synchronization fix. A deterministic JUnit on the real jars confirmed it.

The method in full149 words
  • Method (semi-formal): TLA+ model checking. In a hunt unit on locks and pending deletes, the finder modelled FSDirectory's pending-delete protocol in TLA+: a re-create thread, the retry loop and an operating system that refuses a delete and later allows it.
  • Signal. The hypothesis came from reading the code while writing the model. TLC then violated the invariant NewFileSurvives ("a successfully created, never-deleted file stays visible with its content") in a 10-state trace on the stock semantics; with the synchronization fix it explored 122 distinct states without error. Two vacuity probes confirmed that the create and the retry's delete are reachable.
  • Reduction. The finder wrote a public-API JUnit with latches on the stock jars (fails 20 of 20); it was later minimized to the one test above.
  • The finder labelled it formal; the registry records semi-formal, because the first signal was a code-reading hypothesis that the model checker then witnessed.

Prior reports, history and checks

Prior reports

No matching report found in the recorded searches: 113 queries over GitHub, commits, the JIRA archive, the mailing lists and the web (2026-10-26), refreshed on GitHub 2026-10-31; no open PR changes FSDirectory.java. Closest is #9365 (LUCENE-8318, fixed 2018), the same symptom from a single-threaded cause.

Searches5 searches

Nearest items, none this bug: #9365 / LUCENE-8318 (same symptom, "the dest file might be deleted later by FSDirectory#rename", single-threaded cause, its one-line fix is on main and the race still reproduces there); #9322 / LUCENE-8275 (gave the revive its current shape); #7893 / LUCENE-6835 (introduced pending deletes); #9357 / LUCENE-8310 and #9781 / LUCENE-8735 (IndexWriter avoiding pending names); #11619 / LUCENE-10583 (retries on merge threads; deadlock with code that locks the Directory); #11557 / LUCENE-10521 (a Windows test hang with re-created names, no concurrency); #7829 / LUCENE-6771 (open: WindowsFS does not emulate the NTFS delete-pending state); elastic/elasticsearch#155919 (the single-threaded pattern in a downstream subclass, fixed there). Limits: GitHub search is token-based and was partly rate-limited; no search proves absence; private reports are invisible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26three runs of the scripted prior-report search: 38 issue/PR queries over the identifiers (deletePendingFiles, pendingDeletes, privateDeleteFile, getPendingDeletions), the error messages, the "bringing it back to life" comment, symptoms and test tools (WindowsFS, VirusCheckingFS)no report; #9365 closest (related)
Open PRs2026-10-26 and 2026-10-31the changed files of all 412 open PRs (2026-10-26); open PRs matching FSDirectory or pendingDeletes (2026-10-31)none changes FSDirectory.java
Commits, JIRA archive, downstream code2026-10-266 commit, 8 JIRA and 4 downstream queries (Elasticsearch, OpenSearch, Solr); LUCENE-6835, -8275, -8310, -8318 and SOLR-11892 read with all commentsno report; LUCENE-8318 related; one downstream review comment on the single-threaded pattern
Mailing lists (dev@, java-user@, issues@, commits@) and the web2026-10-2640 list queries, 17 web queriesno report; the list hits are about the unrelated index-level PendingDeletes
GitHub refresh2026-10-3116 issue/PR and 3 commit queries, 145 hits#9365 ranked first; items updated since 2026-10-26 concern other code

History

Introduced in 6.0.0 by LUCENE-6835 (2016), which added pending deletes. The rename path (renameFile before 6.2.0) loses the file in every release since; the createOutput path in all but 6.2.0-7.3.1, where the re-create throws instead. LUCENE-8318 (7.4.0) fixed only a single-threaded cause. Fails on 10.6.0 and main.

Versions tested7 rows

Introduced by LUCENE-6835 "Directory.deleteFile should "own" retrying deletions on Windows" (#7893), merged to master as 8e784699f00 (2016-02-06; a JIRA-era branch, no PR). It brought the pending set, the synchronized retry over a copy, the retry from every mutating call and the revive without the monitor. Masked and re-opened: in 6.2.0-7.3.1 createOutput used CREATE_NEW without deleting the old file first (LUCENE-6171), so re-creating a pending name threw FileAlreadyExistsException; LUCENE-8275 (#9322, 7.4.0) made the revive delete the old file first. LUCENE-8318 (#9365, 7.4.0) fixed only the single-threaded re-add. Releases between the executed ones are classified by a code-shape scan of FSDirectory.java at every release tag. Unchanged on upstream main 2f424fd428d (2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one test fails (gradle; harness -ea and -da)
10.6.0 affectedthe one test fails (-ea and -da)
10.5.2 affectedthe one test fails (-ea); the hunt's tests lose the file on both paths
6.0.0, 6.1.0, 7.4.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedthe history probe loses the file on the createOutput and the rename path; its sequential control keeps it
6.2.0, 6.6.6, 7.3.1 rename onlyrename onto a pending name loses the file; re-creating it throws FileAlreadyExistsException instead
5.5.5 no pending deletesdeleteFile of an open file throws to the caller
main + fix A or B not affectedthe test passes; all 8,898 core tests pass

Independent checks

An independent reproducer, a devil's-advocate verifier, an adjudication (confirmed, high) and a credibility audit each re-ran repros and found it real; a hook-free stress test lost 199 to 321 of 2,000 re-created files on main and 0 with the fix. A cross-model reviewer (OpenAI GPT-5.6, reading only) agreed on the root cause.

Checks and verdicts260 words

Each check had its own lens. The reproducer, the devil's advocate, the adjudication and the audit ran code themselves; for the read-only cross-model reviews a separate run re-ran the hunt's repro (it failed both times).

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn public-API test: the createOutput and rename variants fail 20 of 20 on main, also on 10.5.2, 9.12.3 and 8.11.4; the sequential control passes; a diagnostic shadow that skips no-longer-pending names removes the failure.
cross-model review, refute lens not real (procedural, low)Read-only, could not run code; agreed on the root cause and that static inspection confirms the interleaving. Its objection (the first repro used an internal API) was fixed: the repro uses public API only.
cross-model review, works-as-designed lens real, mediumIntended argument moderate; inputs in contract. Its independence was weakened: it had seen earlier review outputs.
intended-behaviour devil's advocate realNo intended, documented, misuse or reachability argument survives; its hook-free stress test lost 201 of 2,000 files on main and 0 with the fix, and found the secondary NoSuchFileException.
adjudication confirmed, highRe-ran the reproducer's test (2 of 3 fail with assertions on and off, the control passes) and the stress test (199 of 2,000 lost); priority Low to Medium; not a risk to IndexWriter indexes.
credibility audit keepFresh seeds: the hunt's test fails with assertions on and off and passes with the fix; the stress test lost 321 of 2,000 on main, 0 with the fix; added the Windows delete-sharing caveat.

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-331, snapshot of 2026-11-06

Registry title
FSDirectory: a pending-delete retry running on one thread can delete a file that another thread has just re-created (createOutput) or renamed onto the same name, so a file that was written and closed successfully silently disappears
Area
store / FSDirectory pending deletes (delete retry vs. the createOutput/rename revive, thread safety)
Priority
Low, rank 904: An FSDirectory pending-delete retry can delete a re-created file, but only on delete-refusing file systems, in a narrow race, with no in-tree caller.
Impact class
data corruption
Impact note
Applications or downstream code that use an FSDirectory (MMapDirectory, NIOFSDirectory; default settings) directly on a file system that refuses deletes (in practice Windows with an open handle, or a virus scanner) and re-create or rename onto a name whose delete is still pending, while another thread runs any mutating call on the same directory, can silently lose that newly written file (listAll omits it, openInput fails); no scale is needed and every release from 6.0.0 to 10.6.0 is affected, but no in-tree caller is known to re-use a pending name (IndexWriter and the NRT replicator avoid it), so IndexWriter-managed index files are not known to be exposed, and the finder (low), the independent reproduction (low to moderate) and the adjudication (Low to Medium) rate its priority.
Summary
On a file system that refuses to delete open files (Windows, or a virus scanner), you delete a file that a reader still holds open, so FSDirectory keeps it as a pending delete; after the reader closes, one thread re-creates that name with createOutput (or renames another file onto it), writes and closes it, while another thread makes any mutating call on the same directory, for example sync() of an unrelated file. You expect the new file to stay, like any file that was created, closed and never deleted. Instead the other thread's pending-delete retry, which copied the pending set before the revive, deletes the new file: listAll() no longer lists it and openInput() fails (on main under the test framework's WindowsFS: 'x was created and closed successfully but is gone: [other]').
Introduced
8e784699f009b80301306f7fc55225d64b95416b, the merge of branch lucene-6835 into master (mikemccand), LUCENE-6835 'Directory.deleteFile should "own" retrying deletions on Windows' = GitHub #7893; pre-PR era, no pull request [read: git log -S / git blame in the history clone; exec: 5.5.5 not affected, 6.0.0 affected]. It brought the pending-delete set, the synchronized deletePendingFiles() that retries over a copy (new HashSet<>(pendingDeletes)), the retry from every mutating call and the revive in createOutput/renameFile without that monitor; line origins in the branch: 8cd731be50a, 85c546b742b, 24f55abfd50, plus f8bd22e58c9 on master. The createOutput path was masked in 6.2.0-7.3.1 by LUCENE-6171 (CREATE_NEW; 45d2d2e7d34 / a893c645952) and re-opened in 7.4.0 by LUCENE-8275 (#9322; 9f4e6c5da8d / aa341476fdb), which made the revive delete the old file before creating the new one; LUCENE-8318 (#9365; b178321bd1b / 3fe612bed20, 7.4.0) fixed only the single-threaded re-add and left the race unchanged (2016-02-06 (merge 8e784699f00; branch commits 2016-02-03 to 2016-02-07))
First affected version
6.0.0 [exec] (createOutput and rename paths; 5.5.5 and earlier have no pending-delete mechanism [exec 5.5.5])
Affected versions
6.0.0 to 10.6.0 and main ea57360912ed. [exec] the follow-up history probe loses the re-created file on both the createOutput and the rename path on 6.0.0 (-ea and -da), 6.1.0, 7.4.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), and on the rename path only on 6.2.0, 6.6.6 and 7.3.1 (there the createOutput re-create throws FileAlreadyExistsException instead, LUCENE-6171); the finder's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails on main, 10.5.2, 9.12.3 and 8.11.4; [read] the releases in between, by a code-shape scan of FSDirectory.java at every release tag 5.5.0-10.5.2 (four semantic shapes plus a formatting-only change at 9.0.0; the clone has no 8.8.2-8.11.x tags). Not affected: 5.5.5 and earlier [exec 5.5.5]. 10.6.0 (released 2026-10-29) fails too [exec: the one-test repro, -ea and -da, 2026-10-31]. Not run: the release branches. All runs use the test framework's WindowsFS emulation on Linux; nothing was run on real Windows. Not fixed: FSDirectory.java on upstream main cb19aecba8f last changed 2026-06-02 (#16119, unrelated) and no open PR touches it [read].
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
semi-formal: TLA+/TLC, code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record, verdict new: three scripted prior-report searches with 38 GitHub issue/PR queries on apache/lucene, 6 commit queries, 4 downstream-repository queries (Elasticsearch, OpenSearch, Solr), 40 mailing-list queries over dev@, java-user@, issues@ and commits@, 8 JIRA-archive queries and 17 web queries, plus the changed-file lists of all 412 open apache/lucene PRs; the finder's earlier scripted prior-report search (191 hits, 1 high: #9365) agrees; the independent reproduction's and the intended-behaviour check's own GitHub searches were rate-limited); not fixed upstream (FSDirectory.java on upstream main cb19aecba8f last changed 2026-06-02 in #16119, unrelated; no open PR touches FSDirectory.java). Related items, not reports of this defect: #9365 (LUCENE-8318, fixed 2018 in 3fe612bed20, first release 7.4.0: the same symptom, 'the dest file might be deleted later by deletePendingFiles', from a single-threaded cause, a failed retry putting the name back; its one-line fix is on main and does not stop this race, and its regression tests were removed with SimpleFSDirectory in 8a88dd02c6c, 2020), #9322 (LUCENE-8275, gave the revive block its current shape), #7893 (LUCENE-6835, introduced pending deletes), #9357 (LUCENE-8310) and #9781 (LUCENE-8735) (IndexWriter avoiding pending names), elastic/elasticsearch#155919 (a 2026 review comment on the same single-threaded pattern in a downstream FSDirectory subclass, rated 'Low severity since names aren't reused and this is Linux in practice', fixed in that PR), #11619 (LUCENE-10583: an application deadlocked against deletePendingFiles called from merge threads on Windows, so retries do run concurrently in production), #11557 (LUCENE-10521, a Windows test hang with re-created names, no concurrency), #7829 (LUCENE-6771, open: WindowsFS does not emulate the NTFS delete-pending state)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on; removed afterwards) and through the hunt repro harness (assertions on and off); fix A (synchronized revive entered only when the name is pending) through gradle (all of :lucene:core:test, 8,898 tests, assertions on; the closest suites, 908 tests, assertions off) and shadowed in the harness (the repro with assertions on and off and 10 iterations; the hook-free stress test); fix B (the hunt's unconditional variant) through gradle (the closest suites with assertions on and off; all of :lucene:core:test). The 10.6.0 release jars (assertions on and off) and the 10.5.2 jars (assertions on) with the one-test repro. The hunt ran on main ea57360912ed (same lines) with history runs on 5.5.5 (no pending deletes), 6.0.0, 6.1.0, 6.2.0, 6.6.6, 7.3.1, 7.4.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. Upstream main 2f424fd428d (2026-10-30) has no commit touching FSDirectory.java since 645d4fe14a20. OpenJDK 25.0.3, Linux aarch64, with delete refusal emulated by the test framework's WindowsFS; nothing ran on real Windows.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:97 (pendingDeletes = ConcurrentHashMap.newKeySet()), :207-216 (createOutput: maybeDeletePendingFiles, then 'If this file was pending delete, we are now bringing it back to life', remove, privateDeleteFile(name, true), remove, then new FSIndexOutput, without the monitor), :219-234 (createTempOutput skips pending names), :254-266 (rename: the same revive for dest, then Files.move), :312-323 (synchronized deletePendingFiles iterating new HashSet<>(pendingDeletes)), :325-335 (maybeDeletePendingFiles heuristic), :337-367 (privateDeleteFile: Files.delete by name; NoSuchFileException rethrown except on Windows; any other IOException recorded as pending)
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:38 ('Once a file is created it must only be opened for input (openInput), or deleted (deleteFile)'), :83 (createOutput 'Creates a new, empty file in the directory')
  • lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:211-215 (getPendingDeletions counted before inflateGens); lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:93-96, 222-229 (refuses to start with pending deletions)
  • https://github.com/apache/lucene/issues/9365 (LUCENE-8318) and https://github.com/apache/lucene/commit/3fe612bed2080af0b3dd47ece7067ae56794fc82
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:97, :99, :172-195, :207-216, :219-234, :236-241, :243-251, :254-266, :268-274, :299-306, :312-323, :325-335, :337-368, :380-381
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:355-366 (CIFS comment at :364-365)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:211-216; lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:93-95, :222; CopyOneFile.java:51 (createTempOutput); CopyJob.java:263-264 (finish: "Renames all copied (tmp) files to their true file names"); lucene/replicator/src/test/org/apache/lucene/replicator/nrt/SimpleCopyJob.java:177 (dir.rename onto the final name); FSDirectory.java:219-234 (createTempOutput skips pending names)
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/Directory.java:35-40, :83-84
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:97, :99, :210, :212, :360
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:277-280 (synchronized close), :312-335; Directory.java:43-45 (do not synchronize on the Directory; added by LUCENE-10583, 25a842d8719)

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

Repro files

About the diagrams

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.