All findings · Lucene finding · LFV-836 · bbdirectory-rename-drops-concurrent-source-file

ByteBuffersDirectory.rename can silently delete a file that another thread just created under the source name

ByteBuffersDirectory.rename("a", "b") moves the file, then removes the name a once more, so a new a that another thread creates in that instant silently disappears. It affects 7.5.0 to 10.6.0 and main, only for applications that reuse one file name across threads; the fix deletes one line.

Summary

Priority
Low, rank 908ByteBuffersDirectory.rename can silently drop a file that another thread has just created under the source name, but this needs the experimental heap directory, an application that reuses one name concurrently and a tight race between two calls.
Impact
data corruption
Since
7.5.0 (2018, when the class was added)introduced 2018-08-28 · about 8 years
Affected
7.5.0 to 10.6.0 and main
Status
documented
Found by
a TLA+ model of the directory's map calls, then stress tests of the real jar

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

rename publishes the file under the new name, removes exactly that entry from the old name, and then removes the old name once more. Alone, that last call finds nothing; under concurrency it can hit someone else's new file.

Diagram of ByteBuffersDirectory.rename("a", "b") racing with createOutput("a"). Thread 1 runs rename's map calls: get("a") returns the old entry, putIfAbsent("b", entry) publishes it, the checked remove("a", entry) at line 188 frees the name, and remove("a") at line 191 runs once more. Thread 2 creates a new file a between line 188 and line 191, and its close() returns normally. Line 191 removes thread 2's new file. Expected listAll(): [a, b]; actual: [b]. Fix: delete line 191.
The two calls side by side: thread 1's rename("a", "b") and its four map calls, thread 2's createOutput("a") landing between lines 188 and 191, and listAll() ending as [b] instead of [a, b]. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Two threads share one ByteBuffersDirectory and hand files over by name: thread W creates a (createOutput, write, close()), thread R publishes it with rename("a", "b1"), then "b2", and so on, and W creates the next a as soon as the name is free. Nobody deletes anything.
  2. You expect every a whose close() returned to stay until someone renames it: either W's createOutput("a") fails with FileAlreadyExistsException because the old a is still there, or the new a survives. On FSDirectory the same two-thread programs keep every file.
  3. Instead some new a files vanish with no error anywhere: listAll() lacks them and openInput throws NoSuchFileException. One stress run lost 88 of 100,001 hand-offs.
  4. Why: rename makes four map calls, get("a"), putIfAbsent("b1", file), remove("a", file), and then remove("a") once more. After the third call a is already free, so the fourth can only delete a different file: W's new a, if it was created in between.
  5. The one-test repro puts the new a exactly there: listAll() returns [b] instead of [a, b]. Deleting the fourth call fixes it. IndexWriter never re-creates a name it renames away, so Lucene's own commits are not known to be exposed.

Who is affected

Applications that use one ByteBuffersDirectory (opt-in, @lucene.experimental) from several threads and reuse a file name: one thread renames a away while another creates or renames a new a, with no lock of their own. No scale needed. IndexWriter's only rename never re-creates the name, and Lucene's usual test wrapper synchronizes both calls, so it never showed up.

Mechanism, terms and numbers435 words

ByteBuffersDirectory.rename(source, dest) moves the entry with get(source), putIfAbsent(dest, file) and the identity-checked remove(source, file), and then calls files.remove(source) once more (ByteBuffersDirectory.java:191 on main). After the checked remove the moved entry is already gone, so that last call can only remove an entry that another thread put under the source name in the window between the two calls, for example a new file (createOutput) or one renamed onto it. That file disappears without any error, although the other thread's call returned normally and nobody deleted it. The same line was in RAMDirectory's rename after LUCENE-7345 (2016), where it had been the move itself before the checked remove was added, and ByteBuffersDirectory has had it since the class was added (LUCENE-8468, 2018); every release from 7.5.0 to 10.6.0 and main are affected. No Lucene component is known to trigger it; it needs an application that reuses one file name across threads. The fix is deleting the line.

Terms

  • ByteBuffersDirectory: Lucene's heap-resident Directory. Every file is an entry in one ConcurrentHashMap<String, FileEntry> files (ByteBuffersDirectory.java:93); listAll() lists its keys (:126).
  • createOutput(name) creates a new entry only if the name is free: files.putIfAbsent(name, e), else FileAlreadyExistsException (:157-160).
  • rename(source, dest) (:178-192) moves an entry to a name that must not exist yet.

The bug

  1. FileEntry file = files.get(source) (:181).
  2. files.putIfAbsent(dest, file) (:185): the file is now visible under both names, which the rename javadoc allows (Directory.java:125-126).
  3. if (!files.remove(source, file)) throw new IllegalStateException("File was unexpectedly replaced: " + source) (:188-190). FileEntry has no equals (:226), so this removes exactly the moved entry; once it returns, source is free.
  4. files.remove(source); (:191). Alone it removes nothing. If another thread put a new entry under source after step 3, for example with createOutput(source) (:158) or rename(x, source) (:185), this call removes that new entry.
thread R: rename("a", "b")                thread W: createOutput("a") ... close()
  get("a")              -> F1
  putIfAbsent("b", F1)  -> ok
  remove("a", F1)       -> true
                                           putIfAbsent("a", F2) -> ok; write; close() returns normally
  remove("a")           -> removes F2     <- W's new file is gone; listAll() = [b]

Both calls return normally and nobody calls deleteFile, yet listAll() lacks the new a and openInput("a") throws NoSuchFileException. In two-thread stress tests on the stock jar this happens 1 to 222 times per 100,000 hand-offs, in almost every run on the test machine (the finder's test failed 38 of 40 method runs; another test lost 88 of 100,001 with assertions on, 39 with assertions off).

Exposure and likelihood248 words
  • Who is exposed. Only applications that use one ByteBuffersDirectory from several threads with a reused file name, for example a spool or publish-by-rename hand-off with a fixed staging name, without their own lock around rename and createOutput. No setting, index size or data shape is needed; whether it hits depends only on timing.
  • Opt-in. ByteBuffersDirectory is not the default and is @lucene.experimental (ByteBuffersDirectory.java:48); its javadoc says MMapDirectory "is nearly always a better choice" (:43-46), and MIGRATE.md calls it "discouraged in favor of the default MMapDirectory". Wrappers such as FilterDirectory and TrackingDirectoryWrapper delegate rename without serializing it against createOutput.
  • Lucene's own paths are not known to be affected. IndexWriter's only rename is pending_segments_N to segments_N (SegmentInfos.java:1002-1005), and nothing re-creates that pending name. NRTCachingDirectory keeps a ByteBuffersDirectory cache (NRTCachingDirectory.java:72) but its rename un-caches the source and renames on the delegate (:181-186).
  • Why tests never saw it. MockDirectoryWrapper, which wraps most directories that newDirectory() gives Lucene's tests (rarely a bare, unsynchronized RawDirectoryWrapper instead), declares both rename and createOutput synchronized (MockDirectoryWrapper.java:221, :690).
  • What the caller sees. Nothing at the time; later the file is missing from listAll() and openInput throws NoSuchFileException. The content was only on the heap, so it is lost.
  • Likelihood: rare. Nothing has been reported since 7.5.0 (2018); publish-by-rename with a fixed name is a real pattern (rename "is used by IndexWriter to publish commits"), so it is not contrived.

Minimal repro

One JUnit test, single thread, deterministic. rename has no hook between its map calls, so the test swaps the private files map (reflection) for one whose remove(key, value) runs createOutput("a").close() inside the checked remove, right after the removal: exactly where another thread's createOutput can land. A forced interleaving, not a natural scheduling test.

TestByteBuffersDirectoryRenameDropsNewSource.java (one test, lucene/core)52 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.lang.reflect.Field;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestByteBuffersDirectoryRenameDropsNewSource extends LuceneTestCase {

  public void testFileCreatedUnderSourceNameDuringRenameSurvives() throws Exception {
    ByteBuffersDirectory dir = new ByteBuffersDirectory();
    Field files = ByteBuffersDirectory.class.getDeclaredField("files");
    files.setAccessible(true);
    files.set(
        dir,
        new ConcurrentHashMap<String, Object>() {
          @Override
          public boolean remove(Object key, Object value) {
            boolean removed = super.remove(key, value);
            try {
              dir.createOutput("a", IOContext.DEFAULT).close();
            } catch (IOException e) {
              throw new UncheckedIOException(e);
            }
            return removed;
          }
        });
    dir.createOutput("a", IOContext.DEFAULT).close();
    dir.rename("a", "b");
    // expected: the new "a" created right after rename's checked remove survives; actual: deleted
    assertEquals(List.of("a", "b"), List.of(dir.listAll()));
    dir.close();
  }
}
Command
cd $LUCENE
cp $REPRO/TestByteBuffersDirectoryRenameDropsNewSource.java lucene/core/src/test/org/apache/lucene/store/
./gradlew :lucene:core:test --tests org.apache.lucene.store.TestByteBuffersDirectoryRenameDropsNewSource -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
listAll() is [a, b]: the new a, whose createOutput and close() returned, survives the rename of the old one.
Observed
listAll() is [b]: the trailing files.remove(source) deleted the new a, and no call reported an error.

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

More files, environment, full output251 words

Input: one empty, closed file a, renamed to b. The test asserts listAll() is [a, b] afterwards. It sits in org.apache.lucene.store next to TestByteBuffersDirectory, draws no random values, and fails with assertions on and off (20 of 20 with --iters 20).

As a regression test it needs @SuppressForbidden(reason = "swaps the private files map to force the interleaving") (from org.apache.lucene.util) on the method: Lucene's forbiddenApisTest rejects setAccessible without it, and with it :lucene:core:check -x test passes. A maintainer may prefer to take the one-line fix without a reflective test.

Real threads on the stock jar hit the same window: two-thread stress tests (100,000 to 200,000 hand-offs) lose files in almost every run on the test machine (38 of 40 method runs of the finder's test). They show that real scheduling reaches the window; they are evidence, not the repro.

The same file fails unchanged on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars; before 9.1.0 only the LuceneTestCase import changes (org.apache.lucene.util). Setting a private final field by reflection works on JDK 25; newer JDKs are moving to warn about or restrict it (JEP 500).

Regression test: this same test.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2, 9.12.3 and 9.1.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestByteBuffersDirectoryRenameDropsNewSource > testFileCreatedUnderSourceNameDuringRenameSurvives FAILED
    java.lang.AssertionError: expected:<[a, b]> but was:<[b]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:FD637234E020AE47]: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.store.TestByteBuffersDirectoryRenameDropsNewSource.testFileCreatedUnderSourceNameDuringRenameSurvives(TestByteBuffersDirectoryRenameDropsNewSource.java:49)

# with the one-line fix: BUILD SUCCESSFUL (assertions on and off)

Why it looks unintended

  • Directory says a created file "must only be opened for input (openInput), or deleted (deleteFile)"; rename may only show both names "temporarily".
  • No order of the two calls loses the file: createOutput("a") fails with FileAlreadyExistsException or the new a survives, as on FSDirectory.
  • Alone the line does nothing; before LUCENE-7345 (2016) it was the move itself.
The full argument313 words
  • The contract. "Once a file is created it must only be opened for input (openInput), or deleted (deleteFile)." (Directory.java:38-39). The rename javadoc allows one relaxation: "It is permitted for this operation to not be truly atomic, for example both source and dest can be visible temporarily in listAll()" (:125-126). Nothing in it describes deleting a file the rename never moved. The javadoc does not spell out semantics for overlapping calls on one name, so this supports a defect report rather than proving one.
  • No serial order explains it. If W's createOutput("a") runs before rename's remove, it throws FileAlreadyExistsException; if after, the new a survives. Even a non-atomic copy-then-delete rename keeps the old a under source until its one delete, so createOutput("a") keeps failing until then; with nobody calling deleteFile, no new a can appear before that delete, so it would not lose the file either.
  • The line does nothing on its own. After remove(source, file) returns true, source is already gone, so remove(source) removes nothing without another thread.
  • History. Before LUCENE-7345, RAMDirectory.renameFile (renamed rename by LUCENE-7373 before 6.2.0) was fileMap.put(dest, file); fileMap.remove(source);, where the final remove was the move itself. LUCENE-7345 (2016, "RAMDirectory now enforces write-once as well"; c311eb1b7ed on master, branch_6x backport 07920c4fdaa) made it putIfAbsent plus the checked remove(source, file) and kept the old line; LUCENE-8468 (2018) has the same three calls in ByteBuffersDirectory. The history shows the sequence, not what its authors intended. Neither issue discusses rename or concurrent use of one name.
  • The author chose a loud failure for a concurrent change to source: the IllegalStateException("File was unexpectedly replaced") at :189, not a silent delete.
  • Precedent. LUCENE-8318 (#9365), "The dest file might be deleted later by FSDirectory#rename", a single-threaded case of a later delete hitting a file that legitimately holds the name, was accepted and fixed in 2018.
The devil's-advocate check142 words

The strongest case (an intended-behaviour devil's advocate and a review by another model family, OpenAI GPT-5.6): Directory promises no linearizability for overlapping calls on one name, rename may "not be truly atomic", and an application that re-creates a while renaming a away has a race of its own and should synchronize externally (Directory.java:43-45).

Why it fails: the relaxation is narrow (both names visible for a while) and says nothing about deleting a file the rename did not move; no serial order loses it; a writer that only calls createOutput("a") and retries on FileAlreadyExistsException, relying on nothing but createOutput's contract (Directory.java:86-87), still loses files; name reuse after a rename is normal (rename "is used by IndexWriter to publish commits", and MockDirectoryWrapper.rename drops source from its created set, MockDirectoryWrapper.java:257). Verdict: not intended, a defect (low severity).

Suggested fix

Recommended Delete the trailing remove (one line)

Delete the trailing files.remove(source); at ByteBuffersDirectory.java:191. Single-threaded behaviour is unchanged, since the checked remove has already freed the name. Validated: the repro passes; 29 store suites (777 tests) and 25 IndexWriter suites on ByteBuffersDirectory (426 tests) pass; :lucene:core:check -x test passes.

     if (!files.remove(source, file)) {
       throw new IllegalStateException("File was unexpectedly replaced: " + source);
     }
-    files.remove(source);
   }
All options and trade-offs2 options · 342 words

One option was built and validated in a clean scratch worktree of 645d4fe14a20 with gradle; the alternative is not recommended. A separate, loud race stays after the fix and is out of scope: if another thread deletes and re-creates source between rename's get and its checked remove, rename throws IllegalStateException after dest is already published. The repro and the stress tests involve no concurrent deleteFile(source) or close(); those are separate races.

Delete the trailing remove (one line) (validated by the checks) (recommended)

The checked remove(source, file) already completes the move; the unconditional remove(source) after it can only delete another thread's file.

     if (!files.remove(source, file)) {
       throw new IllegalStateException("File was unexpectedly replaced: " + source);
     }
-    files.remove(source);
   }

Trade-offs

None known: one line, no behaviour change without concurrency, nothing added to any hot path.

What the checks validated

  • Stock 645d4fe14a20: the repro fails through gradle with assertions on and off; with the fix it passes, on and off.
  • Every suite in org.apache.lucene.store.* of lucene/core (including TestByteBuffersDirectory and its testRename for all three heap variants, TestNRTCachingDirectory, TestFileSwitchDirectory) plus the repro: 29 suites, 777 tests, 0 failures, 17 skipped.
  • TestIndexWriter*, TestDirectoryReader*, TestSegmentInfos and TestPersistentSnapshotDeletionPolicy with -Ptests.directory=ByteBuffersDirectory: 25 suites, 426 tests, 0 failures, 22 skipped (a bogus value of that option makes the tests fail, so it reaches the test JVM). This is compatibility coverage only: these tests usually get the directory wrapped in the synchronized MockDirectoryWrapper, so they show the fix breaks nothing, not that it closes the race.
  • :lucene:core:check -x test (format, ecj lint, forbidden APIs) with the annotated test: passes.
  • Earlier, with the same change shadowed in: the finder's, the independent reproducer's and the devil's advocate's stress tests all pass (20 of 20 method runs with assertions off and 10 iterations).

Serialize rename against createOutput (not recommended)

One lock around rename, createOutput and deleteFile, as MockDirectoryWrapper has, would also close the window.

Trade-offs

Adds contention to every file creation, and the line would still have no effect in single-threaded use. Not built.

How it was found

While writing a TLA+ model of ByteBuffersDirectory's map calls, an AI finder noticed the second remove; the model checker found an 8-state trace that loses a created file, and the model without that line was clean. Two-thread stress tests on the real jar then lost files the same way.

The method in full141 words

Method: semi-formal (the hypothesis came from reading the code while modelling it; TLC then witnessed it).

  • The finder, an AI tool, modelled the ConcurrentHashMap steps of createOutput, deleteFile and rename for two threads in TLA+ (BBDir.tla) with the invariant NoLostFile: every created file is either legitimately gone or bound to a name. TLC reported a violation in an 8-state trace on the stock steps; the model without the trailing remove is clean over 13,492 distinct states; a vacuity probe is violated as required.
  • A two-thread public-API stress test on the stock jar then lost files in both shapes (a concurrent createOutput("a"), and a concurrent rename(tmp, "a")), and the same programs on FSDirectory kept every file.
  • Independent reproducers wrote their own tests from the claim alone. The single-thread, forced-order repro was cut from them on 2026-10-29.

Prior reports, history and checks

Prior reports

No prior report: three prior-art runs over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web, plus the changed files of all 411 open PRs (2026-10-28), rechecked on 2026-10-31. Closest: LUCENE-8318 (#9365), a single-threaded FSDirectory rename bug fixed in 2018.

Searches3 searches

Verdict new: nobody reports that ByteBuffersDirectory.rename (or RAMDirectory.rename) can delete a file another thread created under the source name, or questions the trailing files.remove(source), and no fix exists. Related, not this bug: #9365 (LUCENE-8318, FSDirectory#rename leaves a stale pending delete that later deletes dest; single-threaded, fixed in 2018); #7901 (LUCENE-6843, RAMDirectory.listAll under concurrent deletes); #10506 and #9709 (opening a file still being written); #8399 (LUCENE-7345) and #9514 (LUCENE-8468), which introduced the code and never discuss rename semantics. Gaps: keyword search; GitHub treats multi-word queries roughly as phrases; two web queries were throttled; list search may miss threads that do not name the class.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream projects; lists; JIRA; web2026-10-28three runs of the scripted prior-report search (2,490 and 494 distinct hits), about 20 manual GitHub phrasings, Elasticsearch, OpenSearch, Solr and Lucene.NET, the JIRA records of LUCENE-7345, LUCENE-8468, LUCENE-5925 and LUCENE-8735only the related items above
Open PRs2026-10-28the changed files of all 411 open apache/lucene PRs and of the 66 updated since 2026-10-24only #11992 touches ByteBuffersDirectory.java, at line 108, not rename
Recheck (GitHub)2026-10-31"ByteBuffersDirectory rename" issues and PRs; ByteBuffersDirectory items updated since 2026-10-28; the changed files of the 59 PRs updated since then; the upstream file historynothing new; upstream main 2f424fd428da still has the line; only #16931 and #16145 touch store/, other files

History

Introduced with the class by f762953aab5 (LUCENE-8468, 2018-08-28) and its branch_7x backport; first released in 7.5.0. The same line was in RAMDirectory's rename after LUCENE-7345 (2016). Executed: 7.5.0 to 10.6.0 and main fail; 7.4.0 has no such class. Not fixed upstream.

Versions tested6 rows

Introduced by f762953aab5be90052ee8bbfe6cbc9f1535356a6 on master, "LUCENE-8468: A ByteBuffer based Directory implementation (and associated classes)." (Dawid Weiss, 2018-08-28; a JIRA patch, no pull request; now #9514), and the same-day branch_7x backport 7eb7b90edf6, which shipped first in 7.5.0. git blame puts every line of rename() there except :183 (exception type only). The same three map calls are in RAMDirectory.rename from 6.2.0 on, where LUCENE-7345 (c311eb1b7ed on master and its branch_6x backport 07920c4fdaa, 2016, #8399; first released in 6.2.0 through the backport) kept the old trailing remove(source) (the method was then renameFile; LUCENE-7373 renamed it rename before 6.2.0): RAMDirectory 6.1.0 passes the stress programs and 6.2.0 fails them. The whitespace-stripped rename() body is identical in all 58 release tags from 7.5.0 to 10.6.0 in the apache/lucene history; the 8.8.2-8.11.x release tags are not in it, and 8.11.4 was executed (fails). Every run used JDK 25.0.3. Upstream main 2f424fd428da (2026-10-30) and branch_10_6 still have the line at ByteBuffersDirectory.java:191; the file's last commit is c2825cfc4b2 (#16063, 2026-05-15).

Versions and branches executed, with results
Version or commitVerdictResult
7.4.0 and earlier no such classByteBuffersDirectory does not exist (compile error)
7.5.0 first: 7.5.0stress test fails (assertions on and off); its own source shadowed unchanged fails 6 of 10, with only the trailing remove deleted passes 10 of 10
7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 affectedstress test fails
9.1.0, 9.12.3, 10.5.2, 10.6.0 affectedthe one-test repro fails
main 645d4fe14a20 affectedthe one-test repro fails through gradle and the standalone runner, assertions on and off; passes with the fix
RAMDirectory 6.1.0 / 6.2.0 (lineage) origin of the line6.1.0 passes 10 of 10, 6.2.0 fails 10 of 10 (LUCENE-7345)

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, a review by another model family (OpenAI GPT-5.6) and an adjudication all rated it real; the adjudication confirmed it with high confidence. The one-test repro still fails on main 645d4fe14a20.

Checks and verdicts351 words

Before the finding was recorded, a read-only refute review by OpenAI GPT-5.6 returned "not real" for a procedural reason only (it could not run any command); it called the mechanism "mechanically plausible", and a separate re-run of the repro failed 2 of 2. No executing cross-model review exists.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateTLC trace (see How it was found); stress test fails 2 of 2 on main (5 of 100,006 lost per method with assertions on; 9 of 100,010 and 1 of 100,002 off), 38 of 40 method runs with 20 iterations, 2 of 2 on 10.5.2 and 9.12.3; passes with the fix; FSDirectory control keeps every file.
Refute review (OpenAI GPT-5.6, read-only) not real (low), proceduralCould not execute; mechanism "mechanically plausible"; its one point (no linearizability promise) is answered in the devil's-advocate check.
Independent reproducer realOwn stress test (200,000 rounds, no deleteFile): 22 and 15 lost (assertions on), 12 and 9 (off), 20 of 20 with 10 iterations; lost = 200,000 minus the successful renames; passes with the fix.
Intended lens (OpenAI GPT-5.6, read-only) real (low)Root cause agrees; intended-behaviour argument weak; a separate re-run of the repro fails 2 of 2.
Devil's advocate real (high that it is a defect, low severity)Closed hand-off test with no pre-check: 88 and 39 of 100,001 lost, 10 of 10 iterations fail, 86 on 10.5.2; single-threaded control passes; passes with the fix.
Adjudication confirmed (high)Re-ran the independent reproduction's test with a new seed: 24 and 3 lost (on), 14 and 20 (off); passes with the fix; not a duplicate.
Registry re-runs and credibility audit reproduced, keepFresh seeds on main a9779ef3c193: the finder's and the intended-behaviour check's tests lose files again; the independent reproduction's test 19 and 6 lost (on), 22 and 5 (off); all pass with the fix.
Repro minimization and this doc verifiedThe forced-order one-test repro fails on 645d4fe14a20 (on, off, 20 of 20 iterations) and on 10.6.0, 10.5.2, 9.12.3, 9.1.0; the fix passes the store and IndexWriter suites.

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

Finding record

LFV-836, snapshot of 2026-11-06

Registry title
ByteBuffersDirectory.rename ends with a leftover unconditional files.remove(source), so a file that another thread creates (createOutput) or renames onto the source name while the rename runs is silently deleted, although that thread's call returned normally
Area
store / ByteBuffersDirectory (rename vs. a concurrent createOutput or rename onto the source name)
Priority
Low, rank 908: ByteBuffersDirectory.rename can silently drop a file that another thread has just created under the source name, but this needs the experimental heap directory, an application that reuses one name concurrently and a tight race between two calls.
Impact class
data corruption
Impact note
Applications that use one ByteBuffersDirectory (the non-default, @lucene.experimental heap directory) from several threads with a reused hand-off name, renaming 'a' away on one thread while another thread creates a new 'a' with createOutput or renames a file onto 'a', can silently lose that new file (listAll omits it, openInput throws NoSuchFileException) although its createOutput and close, or its rename, returned normally and nobody deleted it; no scale is needed, it fails with -ea and -da, and every release from 7.5.0 to 10.6.0 and main is affected; no Lucene component is known to trigger it (IndexWriter's only rename, pending_segments_N to segments_N, never re-creates the source name, NRTCachingDirectory never calls rename on its cache directory, and MockDirectoryWrapper synchronizes rename and createOutput, which hides the race in Lucene's own tests), so IndexWriter-managed index files are not known to be exposed.
Summary
You share one ByteBuffersDirectory between two threads: one renames file 'a' to a new name while the other creates a new 'a' (createOutput, write, close) or renames a temp file onto 'a', and you expect the new 'a' to stay until someone deletes or renames it, as it does on FSDirectory. Instead, now and then the new file vanishes: rename has already moved its own entry with the checked files.remove(source, file) and then runs a leftover unconditional files.remove(source), which can only remove a different entry, the one the other thread has just put under 'a'. Neither call reports an error; afterwards listAll lacks the file and openInput throws NoSuchFileException (stress tests lose between 1 and a few hundred files per 100,000 rounds on every run).
Introduced
f762953aab5be90052ee8bbfe6cbc9f1535356a6 (master, 'LUCENE-8468: A ByteBuffer based Directory implementation (and associated classes).', Dawid Weiss; a JIRA patch, no pull request; the issue is now apache/lucene#9514) and its same-day branch_7x backport 7eb7b90edf6, which shipped first, in 7.5.0 [read: git blame puts every line of rename() at f762953aab5 except line 183 (ca54137c8e6, exception type only)] [exec: 7.4.0 has no ByteBuffersDirectory (compile error); with 7.5.0 jars, its ByteBuffersDirectory.java shadowed unchanged fails 6 of 10 method runs and with only the trailing remove deleted passes 10 of 10]. The three map calls were copied from RAMDirectory.rename, where LUCENE-7345 (c311eb1b7ed, 2016-06-20, now #8399, first released in 6.2.0) replaced 'put(dest, file); remove(source)' with putIfAbsent(dest, file) plus the checked remove(source, file) but kept the old trailing remove(source) [exec: RAMDirectory 6.1.0 passes 10 of 10, 6.2.0 fails 10 of 10]. Present since ByteBuffersDirectory was created, not a regression of that class (2018-08-28 (f762953aab5 and the branch_7x backport 7eb7b90edf6); the pattern in RAMDirectory 2016-06-20 (c311eb1b7ed, LUCENE-7345))
First affected version
7.5.0 [exec] (tag commit dated 2018-09-17, through the branch_7x backport; the master commit is first in 8.0.0); 7.4.0 and earlier have no ByteBuffersDirectory [exec: compile error]. An earlier reading said 'since 8.0'; the executed history shows 7.5.0. Lineage, different class: RAMDirectory had the same leftover from 6.2.0 until it was removed in 9.0 [exec 6.2.0, 7.4.0, 8.11.4]
Affected versions
every release that has ByteBuffersDirectory, 7.5.0 through 10.6.0, and main a9779ef3c193 [exec: the one-test repro fails on the 10.6.0 jars (2026-10-31); the finder's two-method JUnit (100,000 rounds per method; a package and API adapted copy for 7.5.0-9.0.0) fails both methods on 7.5.0 (-ea, -da), 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea, -da); the independent reproduction's own JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3, the intended-behaviour check's on main (-ea, -da) and 10.5.2] [read: the whitespace-stripped rename() body is identical in every release tag from 7.5.0 to 10.5.2 (7 file blobs, at least one release per blob executed; 8.8.2-8.11.3 are not tagged in the history clone, 8.11.4 executed)]; not fixed on upstream main 20ced71c1e6, branch_10x or branch_10_6 (2026-10-27/28) [read: GitHub API; re-checked on upstream main 2026-10-28]. All runs used JDK 25 on one aarch64 Linux host
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by TLA+/TLC, stress
Upstream and prior reports
none found (2026-10-28, the prior-report record, verdict new: three scripted prior-report searches over GitHub issues, PRs and commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web (2,490 and 494 distinct hits; none is this bug); manual gh searches of apache/lucene issues and PRs; the changed files of all 411 open apache/lucene PRs (ByteBuffersDirectory.java is touched only by #11992, line 108, not rename) and of the 66 PRs updated since 2026-10-24 (none touches ByteBuffersDirectory.java or Directory.java); downstream searches in Elasticsearch, OpenSearch, Solr and Lucene.NET; the JIRA records of LUCENE-7345, LUCENE-8468, LUCENE-5925 and LUCENE-8735; plus the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches). Re-checked 2026-10-28: of the 59 PRs updated since 2026-10-26 only #16898, #16804 and #16145 touch store/ (IOContext, MemorySegmentIndexInput, PrefetchBackoff), no issue created since 2026-10-20 matches 'rename', and upstream main still ends rename() with files.remove(source) at ByteBuffersDirectory.java:191 (last commit on the file c2825cfc4b2, #16063, author and commit date 2026-05-15). Nearest items, none of them this bug: LUCENE-8318 / #9365 (FSDirectory#rename leaves a stale pending delete that later deletes dest: single-threaded, different class, fixed in 2018), LUCENE-6843 / #7901 (RAMDirectory.listAll returns null under concurrent deletes), LUCENE-9467 / #10506 and LUCENE-8663 / #9709 (opening a file still being written), and the issues that introduced the code, #8399 (LUCENE-7345) and #9514 (LUCENE-8468), whose discussions never mention rename semantics or concurrency
Lucene commit tested
main 645d4fe14a20 (the one-test repro, standalone and through gradle, assertions on and off; the one-line fix through the org.apache.lucene.store suites, the IndexWriter suites on ByteBuffersDirectory and :lucene:core:check -x test, 2026-10-31); the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars (the same test). The hunt, the verifiers and the adjudication ran two-thread stress tests on main a9779ef3c193, where every cited file is identical, and on releases from 7.5.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:48 (at a9779ef3c193) (@lucene.experimental), :155-162 (createOutput: files.putIfAbsent(name, e) :158), :177-192 (rename: files.get(source) :181, files.putIfAbsent(dest, file) :185, 'if (!files.remove(source, file)) throw new IllegalStateException("File was unexpectedly replaced: " + source)' :188-190, then 'files.remove(source);' :191), :226 (FileEntry; no equals override, so remove(source, file) matches by identity)
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:32-41 (at a9779ef3c193) (class contract: 'Once a file is created it must only be opened for input (openInput), or deleted (deleteFile)'), :121-133 (rename: dest must not exist; 'It is permitted for this operation to not be truly atomic, for example both source and dest can be visible temporarily in listAll()'; 'This method is used by IndexWriter to publish commits')
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:1005 (at a9779ef3c193) (the only IndexWriter rename: pending_segments_N to segments_N)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:93, (at 645d4fe14a20) :126, :155-162, :177-192, :226
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:121-133 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:43-48 (at 645d4fe14a20)
  • lucene/MIGRATE.md:1359-1360 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:1002-1005 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/NRTCachingDirectory.java:72, (at 645d4fe14a20) :181-186
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:221, (at 645d4fe14a20) :690
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:131 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:2151, (at 645d4fe14a20) :2276-2282
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:32-41, (at 645d4fe14a20) :43-45, :86-87, :121-133
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:188-191 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:257 (at 645d4fe14a20)
Suggested fix
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:221, (at 645d4fe14a20) :690
History
  • a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java (:178-192)
Diagrams
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:155-162, (at 645d4fe14a20) :178-192

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

Repro files

About the diagrams

A step-by-step picture of the four map calls and the other thread's createOutput, made in parallel with this doc from the code at 645d4fe14a20 and executed runs; the PNG is the summary still.

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.