All findings · Lucene finding · LFV-1158 · bbdirectory-failed-rename-leaves-dest-bound

ByteBuffersDirectory.rename that fails with IllegalStateException still leaves the file under its new name

When two threads rename the same file in a ByteBuffersDirectory, rename("a", "x") and rename("a", "y"), the losing call throws IllegalStateException but keeps the file under its new name, so listAll() shows [x, y] after the call has failed. It affects 7.5.0 to 10.6.0 and main; the fix is one line.

Summary

Priority
Low, rank 909Two concurrent renames of one source file on a ByteBuffersDirectory leave the file listed under both new names, and the losing call throws a misleading IllegalStateException, in every release since 7.5.0. It needs the experimental heap directory and unsynchronized concurrent renames of the same name, which no Lucene component does, so it is contrived.
Impact
minor
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 proof-obligation audit of the rename contract, witnessed by an executable repro

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

Before its throw at :189, rename makes three map calls: read the file under source, publish it under dest, then remove source only if it still holds that file, else throw. Two renames of one source both publish; the loser's removal fails and it throws, but nothing takes its dest back out.

Diagram of two concurrent ByteBuffersDirectory renames of file a. Thread 2 runs rename("a", "y"): get("a") returns the entry, putIfAbsent("y", entry) publishes it. Thread 1 runs rename("a", "x"): get, putIfAbsent("x", entry), and the checked remove("a", entry) succeeds, so it returns normally. Thread 2's checked remove then fails and it throws IllegalStateException without removing y. Expected listAll(): [x]; actual: [x, y]. Fix: remove(dest, entry) before the throw.
The two renames side by side: both read a and publish their new names, only one removal of a succeeds, and the loser throws with y still bound, so listAll() ends as [x, y] instead of [x]. Select the still to open it at full size. Generated with AI, checked against the code.
  1. A ByteBuffersDirectory holds one file, a. Two threads rename it at the same time: thread 1 calls rename("a", "x"), thread 2 calls rename("a", "y").
  2. You expect one winner and a loser that leaves nothing behind: the file under exactly one name. Run one after the other, the second call throws NoSuchFileException and only the winner's name remains; on NIOFSDirectory (this JDK and platform) the same two threads kept one name in 20,000 of 20,000 rounds.
  3. rename first publishes the file under the new name, then removes a only if a still holds that file, else it throws. Both calls publish (x and y differ), and only one removal of a can succeed.
  4. Instead the loser throws IllegalStateException: File was unexpectedly replaced: a and never takes its new name back: listAll() is [x, y] until something deletes one of the names. Real threads hit it in 20 of 20 test runs.
  5. The fix is one line: files.remove(dest, file) before the throw.

Who is affected

Only applications that, without their own lock, change a file name on a ByteBuffersDirectory (opt-in, @lucene.experimental) while another thread renames it; executed: two renames of one file, as in a claim-by-rename work queue. The loser fails loudly; the extra name is silent for everyone else. No Lucene component does this: IndexWriter's only rename runs under its write lock.

Mechanism, terms and numbers406 words

ByteBuffersDirectory.rename(source, dest) reads the entry under source, publishes it under dest with putIfAbsent, and then removes it from source with the identity-checked remove(source, file); if that remove finds nothing, it throws IllegalStateException("File was unexpectedly replaced: " + source) without taking dest back out (ByteBuffersDirectory.java:181-189 on main). When two renames of the same source run together, both publish their own dest, only one checked remove succeeds, and the loser throws with its dest still bound: the same file stays listed under both new names after the call has failed, until something deletes one of them, although one of the renames reported failure. Run one after the other, the second call throws NoSuchFileException and one name remains, and NIOFSDirectory keeps one name under the same two-thread program. The code shape came from RAMDirectory's rename (LUCENE-7345, 2016) and has been in ByteBuffersDirectory 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. The fix is files.remove(dest, file); before the throw; it belongs in the same patch as LFV-836, the other one-line defect in this method.

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). FileEntry has no equals (:226), so map removals by value are by identity.
  • 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 "temporarily" (Directory.java:125-126).
  3. if (!files.remove(source, file)) throw new IllegalStateException("File was unexpectedly replaced: " + source) (:188-189). On this path nothing removes dest.
thread 2: rename("a", "y")              thread 1: rename("a", "x")
  get("a")             -> F
  putIfAbsent("y", F)  -> ok              get("a")             -> F
                                          putIfAbsent("x", F)  -> ok
                                          remove("a", F)       -> true   (returns normally)
  remove("a", F)       -> false
  throw IllegalStateException             <- "y" still maps to F
listAll() = [x, y]

The race needs only that both calls read a before the winner removes it. The message is also misleading here: a was moved by the other call, not replaced. The trailing files.remove(source) at :191 is a different defect (LFV-836) with a different fix; its fix alone leaves this one in place (the independent reproducer's test still fails with it).

Exposure and likelihood288 words
  • Who is exposed. Applications that, with no lock of their own, change one source name on a ByteBuffersDirectory from another thread while a rename of it runs. The executed case is two rename calls with the same source, for example workers that claim a job file by renaming it and expect one winner; a rename racing with deleteFile plus createOutput of its source leaves dest behind the same way by reading, but was not executed. No setting, index size or data shape is needed, and it fails with assertions on and off.
  • Opt-in class. ByteBuffersDirectory is not the default and is @lucene.experimental (: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".
  • What the caller sees. The losing call throws an unchecked IllegalStateException (rename declares only IOException) with a message that misdescribes the case. Everyone else sees the file under a name whose rename failed, in listAll() and, for a closed file as here, with its complete, correct content through openInput (by reading; a file still open for writing makes openInput throw AccessDeniedException, :241-245), until someone deletes it; a later createOutput("y") gets FileAlreadyExistsException (:158-159, by reading). File contents are never wrong.
  • Lucene's own paths are not known to be affected. IndexWriter's only rename is pending_segments_N to segments_N (SegmentInfos.java:1005), under the write lock, on a name nothing else touches. Most test directories are wrapped in MockDirectoryWrapper, whose rename is synchronized (MockDirectoryWrapper.java:221), and BaseDirectoryTestCase.testRename is single-threaded, so the tests never saw it.
  • Likelihood: contrived. No matching report was found in the recorded searches, although the class has shipped since 7.5.0 (2018).

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 putIfAbsent("y", ...) runs the competing rename("a", "x") right after y is published: exactly where a second thread's rename can land. A forced interleaving; real threads hit it too.

TestByteBuffersDirectoryFailedRenameLeavesDest.java (one test, lucene/core)56 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;
import org.apache.lucene.util.SuppressForbidden;

public class TestByteBuffersDirectoryFailedRenameLeavesDest extends LuceneTestCase {

  @SuppressForbidden(reason = "swaps the private files map to force the interleaving")
  public void testFailedRenameLeavesNoDest() throws Exception {
    ByteBuffersDirectory dir = new ByteBuffersDirectory();
    Field files = ByteBuffersDirectory.class.getDeclaredField("files");
    files.setAccessible(true);
    files.set(
        dir,
        new ConcurrentHashMap<String, Object>() {
          @Override
          public Object putIfAbsent(String key, Object value) {
            Object previous = super.putIfAbsent(key, value);
            if (key.equals("y")) {
              try {
                dir.rename("a", "x");
              } catch (IOException e) {
                throw new UncheckedIOException(e);
              }
            }
            return previous;
          }
        });
    dir.createOutput("a", IOContext.DEFAULT).close();
    expectThrows(IllegalStateException.class, () -> dir.rename("a", "y"));
    // expected: the rename to "y" threw, so only "x" is left; actual: [x, y]
    assertEquals(List.of("x"), List.of(dir.listAll()));
    dir.close();
  }
}
Command
cd $LUCENE
cp $REPRO/TestByteBuffersDirectoryFailedRenameLeavesDest.java lucene/core/src/test/org/apache/lucene/store/
./gradlew :lucene:core:test --tests org.apache.lucene.store.TestByteBuffersDirectoryFailedRenameLeavesDest -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
rename("a", "y") throws and leaves nothing behind: listAll() is [x].
Observed
It throws, but y stays bound to the file: listAll() is [x, y].

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

More files, environment, full output213 words

Input: one empty, closed file a. The test expects rename("a", "y") to throw IllegalStateException (it does) and then asserts listAll() is [x]. 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 20 iterations), and on the 10.6.0 and 9.12.3 release jars.

The @SuppressForbidden annotation is needed: without it :lucene:core:forbiddenApisTest rejects AccessibleObject#setAccessible(boolean); with it :lucene:core:check -x test passes. No test in lucene/core uses setAccessible today, so a maintainer may prefer to take the fix without a reflective test.

Real threads reach the same state with public API only: two-thread tests that release both renames together fail in 20 of 20 runs on main, each within its first 31 rounds. They are evidence, not the repro.

Setting a private final field by reflection works on JDK 25; newer JDKs are moving to warn about or restrict it (JEP 500). With fix B (a NoSuchFileException for the loser), change the expectThrows type.

Regression test: this same test.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0 and 9.12.3
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestByteBuffersDirectoryFailedRenameLeavesDest > testFailedRenameLeavesNoDest FAILED
    java.lang.AssertionError: expected:<[x]> but was:<[x, y]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:4DE3EE4B61D3D00A]: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.TestByteBuffersDirectoryFailedRenameLeavesDest.testFailedRenameLeavesNoDest(TestByteBuffersDirectoryFailedRenameLeavesDest.java:53)

Why it looks unintended

  • Directory.rename allows both names only "temporarily"; here a name outlives the call, owned by a call that reported failure.
  • No serial order gives [x, y]: one after the other, the second call throws NoSuchFileException; NIOFSDirectory keeps one name.
  • Lucene's own failure convention removes dest: copyThroughCreateOutput says "On failure, deletes the file named dest".
The full argument288 words
  • The contract. "Renames source file to dest file where dest must not already exist"; "It is permitted for this operation to not be truly atomic, for example both source and dest can be visible temporarily in listAll()" (Directory.java:121-133). On the natural reading, an extra name that outlives the call, held by a call that threw, is not "temporarily"; the javadoc does not spell out rollback or failure atomicity, so this is a reading of the contract, not an explicit guarantee.
  • No serial order gives [x, y]. Sequentially, the second rename("a", ...) finds no a and throws NoSuchFileException (:181-183); the cross-model review executed this control. FSDirectory.rename moves the file with one Files.move(..., ATOMIC_MOVE) (FSDirectory.java:265, after its pending-delete checks), and the devil's-advocate verifier's two-thread program on NIOFSDirectory kept exactly one name in 20,000 of 20,000 rounds (behaviour on this JDK and platform, not a documented Directory requirement).
  • Lucene removes dest when an operation fails. copyThroughCreateOutput: "On failure, deletes the file named dest" (Directory.java:184-185). SegmentInfos.finishCommit deletes dest itself when the step after a successful rename fails (:1006-1013); when rename itself throws, its catch deletes only pending_segments_N (:1014-1017) and does not clean up dest.
  • The check exists to detect interference on source. The identity-checked remove at :188 notices that source no longer holds the file, and the author chose to fail; a half-published dest goes against that choice.
  • History. The shape (publish dest, checked remove, throw) came from LUCENE-7345 (#8399, 2016, "RAMDirectory now enforces write-once as well"), whose discussion never mentions the failure branch; LUCENE-8468 (#9514, 2018) copied it. Nothing in the history says a failed rename was meant to keep dest.
The devil's-advocate check143 words

The strongest case (the devil's-advocate verifier and the review by another model family, OpenAI GPT-5.6, which rated it "moderate"): Directory promises no failure atomicity or linearizability for two conflicting renames of one name; its class javadoc tells applications that need external synchronization to use their own lock (Directory.java:43-45); the class is experimental; and the loser does throw, an unchecked fail-fast check that arguably promises nothing about the state it leaves.

Why it fails: the javadoc's relaxation is both names "temporarily", not an extra name that outlives a failed call; no documented precondition is broken (public API, fresh dest names); Lucene's own failure convention removes dest; and the rollback is one identity-checked line on the failure path. What survives is a severity argument: the loser is loud, the trigger is an application-level race, nothing in Lucene triggers it. Hence priority Low.

Suggested fix

Recommended Withdraw dest before the throw (one line)

Add files.remove(dest, file); before the throw at ByteBuffersDirectory.java:189; the identity check leaves a different file under dest alone. Validated: the repro passes (assertions on and off), 29 store suites (778 tests) and :lucene:core:check -x test pass. A combined patch with LFV-836's one-line fix in the same method is validated too.

     if (!files.remove(source, file)) {
+      files.remove(dest, file); // withdraw the publication of a rename that did not happen
       throw new IllegalStateException("File was unexpectedly replaced: " + source);
     }
All options and trade-offs3 options ยท 564 words

Both built options were validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03). Workaround until fixed: do not rename one source name from two threads without your own lock (not on the Directory instance, Directory.java:43-45), delete the dest of a rename that threw, or use FSDirectory / MMapDirectory.

Withdraw dest before the throw (one line) (validated by the checks) (recommended)

Undo this call's own publication when its checked remove fails. FileEntry uses identity, so a different file that someone put under dest meanwhile is left alone; the check cannot tell this call's binding apart from a later re-binding of the same entry to dest (see the trade-offs). Single-threaded behaviour is unchanged: the branch is reached only under interference.

     if (!files.remove(source, file)) {
+      files.remove(dest, file); // withdraw the publication of a rename that did not happen
       throw new IllegalStateException("File was unexpectedly replaced: " + source);
     }

Trade-offs

One line on a failure path only, scoped to the race shown here (two renames of one source). The identity check cannot tell this call's binding from a later re-binding of the same entry, so in one longer interleaving, traced by reading and not executed, it removes a binding it did not make: rename("s", "d") publishes d; another thread renames d to z; a third deletes s and creates a new s; the second renames z back to d; the first call's checked remove of s then fails and the fix removes the second thread's successful d, leaving the file with no name, where stock code keeps it under d. It needs four interleaved operations, including a delete-and-recreate of the source (raised by the cross-model fact check of this doc).

What the checks validated

  • Stock 5adff78bb168: 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 plus the repro (including TestByteBuffersDirectory, 171 tests with 6 skipped by assumptions, and TestMultiByteBuffersDirectory): 29 suites, 778 tests, 0 failures, 17 skipped.
  • :lucene:core:check -x test (format, ecj lint, forbidden APIs) with the annotated test: passes.
  • Earlier, with the same line shadowed in: the finder's and the independent reproducer's two-thread tests pass all 100,000 rounds, and the cross-model review's passes all 20,000 rounds, with assertions on and off.

One rename() patch for both bugs (this fix plus LFV-836's) (validated by the checks)

The withdraw-dest line plus the deletion of the trailing files.remove(source); at :191 (LFV-836's fix). LFV-836's fix alone does not fix this bug.

     if (!files.remove(source, file)) {
+      files.remove(dest, file); // withdraw the publication of a rename that did not happen
       throw new IllegalStateException("File was unexpectedly replaced: " + source);
     }
-    files.remove(source);
   }

Trade-offs

The same as the one-line fix (same scope, same traced interleaving).

What the checks validated

  • Every org.apache.lucene.store.* suite with both one-test repros (this one and LFV-836's): 30 suites, 779 tests, 0 failures, 17 skipped; both repros pass.

Also throw NoSuchFileException when the source is gone (not built)

After the withdraw-dest line, throw NoSuchFileException(source) when files.get(source) is null (the other call moved it), as the sequential case and FSDirectory do, and keep IllegalStateException for a genuinely replaced source.

Trade-offs

Changes the exception type callers see in this race (to a declared IOException), and the repro's expectThrows type must change with it. Not built or tested.

Downstream note

Downstream products that use ByteBuffersDirectory only through IndexWriter are not exposed; only application code that changes one file name from two threads while it is being renamed is.

How it was found

A proof-obligation audit of ByteBuffersDirectory against the Directory.rename contract asked what a rename that throws leaves behind, and found the failure branch throws after dest is published. A two-thread public-API JUnit then witnessed the leftover name on the real jar; the forced-order one-test repro was cut from it.

The method in full129 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder, an AI tool, read rename against Directory.rename (Directory.java:121-133): dest may be visible early, but only "temporarily"; the audit's obligation was that a call that fails leaves no lasting effect. The branch at ByteBuffersDirectory.java:188-189 throws after putIfAbsent(dest) has published the file, so that obligation is not met when the checked remove fails.
  • A two-thread public-API JUnit then showed it on the real jar: one call throws, both names stay (main, assertions on and off; 20 of 20 runs; 10.6.0, 9.12.3). An independent reproducer, an executed review by another model family (OpenAI GPT-5.6) and a devil's-advocate verifier wrote their own tests from the claim alone. The single-thread, forced-order repro was cut on 2026-11-02.

Prior reports, history and checks

Prior reports

No prior report: 53 scripted and about 35 manual searches over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web, plus the changed files of every open PR (2026-11-02), rechecked the same day. The exact message "unexpectedly replaced" appears in no issue or PR.

Searches3 searches

No report found in the documented searches says that ByteBuffersDirectory.rename (or RAMDirectory.rename) keeps dest when its checked remove fails, or that two concurrent renames of one source leave two names, and no fix exists. The only partial mention is the hunt's own LFV-836 page, which names this missing rollback through a delete-and-recreate trigger as out of scope. Related, not this bug: #8399 (LUCENE-7345, introduced the shape), #9514 (LUCENE-8468), #6987 (LUCENE-5925) and #9461 (LUCENE-8415), the origin and wording of the rename contract; #9365 (LUCENE-8318, a single-threaded FSDirectory#rename bug, fixed in 2018). Gaps: keyword search; GitHub treats multi-word queries roughly as phrases; 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; Elasticsearch, OpenSearch, Solr, Lucene.NET; dev@, java-user@, issues@, commits@; JIRA; web2026-11-02the scripted prior-report search with 53 queries (2,675 distinct hits) and about 35 manual GitHub searchesonly the related items above; "unexpectedly replaced" appears only in the commit mails that carry the code
Open PRs2026-11-02the changed files of every open apache/lucene PRonly #11992 touches ByteBuffersDirectory.java, a toArray line, not rename
Recheck (GitHub)2026-11-02"ByteBuffersDirectory rename", "unexpectedly replaced", PRs updated or created since 2026-10-31, the upstream file historynothing new; upstream main 1c2072f0284 still throws at :189 without removing dest

History

Introduced with the class by LUCENE-8468 (2018-08-28; 7eb7b90edf6 on branch_7x, first released in 7.5.0). The shape came from RAMDirectory's rename (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 LUCENE-8468, "A ByteBuffer based Directory implementation (and associated classes)" (Dawid Weiss, 2018-08-28; a JIRA patch, no pull request; now #9514): 7eb7b90edf6fc780df6bee58922a714bd665ab34 on branch_7x, first released in 7.5.0, and f762953aab5be90052ee8bbfe6cbc9f1535356a6 on master, first in 8.0.0. git blame puts every line of rename() at f762953aab5 except :183 (ca54137c8e6, exception type only). The shape came from RAMDirectory.rename after LUCENE-7345 (c311eb1b7ed, branch_6x 07920c4fdaa, 2016-06-20, first in 6.2.0); RAMDirectory 6.2.0, 7.4.0 and 8.11.4 leave the same extra name, and RAMDirectory was removed in 9.0.0. The whitespace-normalised rename() text is identical at 7.5.0, 7.7.3, 8.0.0, 8.8.1, 9.0.0, 9.12.3, 10.0.0, 10.6.0 and main; 8.8.2-8.11.3 are inferred (not tagged in the local history clone). Every run used JDK 25.0.3. Upstream main 1c2072f0284 (2026-11-02) still throws at :189 without removing dest; 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.0the two-thread test fails, assertions on and off
7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0 affectedthe two-thread test fails
9.12.3, 10.6.0 affectedthe two-thread test and the one-test repro fail
main 5adff78bb168 affectedthe one-test repro fails through gradle and a standalone JUnit run, assertions on and off; passes with the fix
RAMDirectory 6.2.0, 7.4.0, 8.11.4 (lineage) origin of the shapethe same leftover name (LUCENE-7345); 6.1.0 also ends with two names but both calls succeed, an older, different behaviour

Independent checks

An independent reproducer, an executed review by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (medium confidence, priority Low). This doc's gradle runs reproduced it on main and validated two fixes.

Checks and verdicts259 words

The cross-model review ran on main ea57360912ed and its tests were re-run on 5adff78bb168 with the same results; every other check ran on 5adff78bb168 or a release.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedTwo-thread JUnit fails on main (assertions on, round 14; off, round 0), 20 of 20 runs with assertions off, 10.6.0 and 9.12.3; passes 100,000 rounds with the fix.
independent reproducer realOwn two-thread test fails on main (on, off), 20 of 20 runs, 10.6.0, 10.5.2, 9.12.3; own one-line fix passes 100,000 rounds; LFV-836's fix alone still fails.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own latch test fails (three seeds, on and off); sequential control passes (NoSuchFileException, one name); the fix passes it and TestByteBuffersDirectory (165 executed tests, 6 more skipped by assumptions); intended argument moderate.
devil's-advocate verifier real (medium)Own test fails on main and 10.6.0; NIOFSDirectory keeps one name in 20,000 of 20,000 rounds; no intended-behaviour defence holds, only a severity one.
adjudication confirmed (medium); minor, LowThe reproducer's test with a new seed fails (on, off) and passes with the fix; not a duplicate of LFV-836 (different fix site).
separate re-run, audit and final repro check reproduced; confirmed, LowFresh seeds fail and pass with the fix; the forced-order one-test repro fails on and off and passes with the fix.
this doc reproduced, fix validatedGradle on 5adff78bb168 (fail, on and off); standalone 20 of 20, 10.6.0, 9.12.3 (fail); both fixes through the store 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,279 words

Finding record

LFV-1158, snapshot of 2026-11-06

Registry title
ByteBuffersDirectory.rename throws IllegalStateException without removing the dest it has already published, so two concurrent renames of one source leave the file listed under both new names
Area
store / ByteBuffersDirectory (rename failure path: two concurrent renames of one source)
Priority
Low, rank 909: Two concurrent renames of one source file on a ByteBuffersDirectory leave the file listed under both new names, and the losing call throws a misleading IllegalStateException, in every release since 7.5.0. It needs the experimental heap directory and unsynchronized concurrent renames of the same name, which no Lucene component does, so it is contrived.
Impact class
minor
Impact note
Only applications that call rename on one ByteBuffersDirectory (the non-default, @lucene.experimental heap directory) from two threads with the same source name are hit: the losing call fails loudly with an unchecked IllegalStateException whose message ('File was unexpectedly replaced') misdescribes what happened, but it leaves its dest name bound to the file for good, so every later listAll() and openInput() sees the complete file under a name whose rename reported failure; no scale is needed, it fails with -ea and -da, every release from 7.5.0 to 10.6.0 and main is affected, and no Lucene component is known to trigger it (IndexWriter's only rename, pending_segments_N to segments_N, runs under the write lock on a name nothing else touches).
Summary
Two threads call rename on one ByteBuffersDirectory with the same source and different destinations, rename('a', 'x') and rename('a', 'y'); you expect one call to win and the other to fail and leave nothing behind, which is what happens when the calls run one after the other (the second throws NoSuchFileException) and on FSDirectory. Instead both calls first publish the file under their own dest, then only one of them can remove 'a', and the loser throws IllegalStateException('File was unexpectedly replaced: a') without withdrawing its dest. Afterwards listAll() shows the same file as both 'x' and 'y', permanently, although one of the two renames reported failure.
Introduced
7eb7b90edf6fc780df6bee58922a714bd665ab34 (branch_7x, '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; shipped first, in 7.5.0) and the same change on master, f762953aab5be90052ee8bbfe6cbc9f1535356a6 (first in 8.0.0) [read: git log -S 'unexpectedly replaced' and git blame of rename() on main: every line from f762953aab5 except :183, ca54137c8e6, exception type only] [exec: 7.4.0 has no ByteBuffersDirectory (compile error), 7.5.0 fails with -ea and -da]. The code shape (putIfAbsent(dest), then an identity-checked remove(source, file) that throws without withdrawing dest) was taken from RAMDirectory.rename, where LUCENE-7345 (c311eb1b7ed on master, 07920c4fdaa on branch_6x, 2016-06-20, now #8399, first released in 6.2.0) added it [exec: RAMDirectory 6.2.0, 7.4.0 and 8.11.4 show the same leftover dest; 6.1.0 leaves two names with both calls succeeding, a different, older behaviour]. Present since ByteBuffersDirectory was created, not a regression of that class (2018-08-28 (author date of 7eb7b90edf6 and f762953aab5); the code shape in RAMDirectory 2016-06-20 (LUCENE-7345))
First affected version
7.5.0 [exec] (through the branch_7x commit 7eb7b90edf6; the master commit is first in 8.0.0); 7.4.0 and earlier have no ByteBuffersDirectory [exec: compile error]. Lineage, different class: RAMDirectory had the same missing rollback from 6.2.0 until it was removed in 9.0.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 5adff78bb168 [exec: the finder's two-thread JUnit fails on 7.5.0 (-ea, -da), 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.6.0 (-ea, -da) and main (-ea, -da; 20 of 20 runs with -da); the independent reproduction's own JUnit fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3; the intended-behaviour check's on main (-ea, -da) and 10.6.0] [read: the whitespace-normalised rename() text is identical at 7.5.0, 7.7.3, 8.0.0, 8.8.1, 9.0.0, 9.12.3, 10.0.0, 10.6.0 and main; 8.8.2-8.11.3 are inferred (not tagged in the local history clone) between executed failing releases]; not fixed upstream (main tip 1c2072f0284, 2026-11-02: the file's last commit is c2825cfc4b2, #16063, author and commit date 2026-05-15). Not new in 10.6. 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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-02: a scripted prior-report search with 53 queries over GitHub issues, PRs and commits, downstream projects (Elasticsearch, OpenSearch, Solr, Lucene.NET), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (2,675 distinct hits, none this bug); manual GitHub searches of apache/lucene issues and PRs (the exact message 'unexpectedly replaced' has 0 hits in issues and PRs; it appears only in the commit mails that carry the code, LUCENE-7345, LUCENE-8468, LUCENE-8474); the changed files of every open apache/lucene PR (ByteBuffersDirectory.java is touched only by #11992, a toArray line, not rename); the finder's, the intended-behaviour check's and the adjudication's searches agree). Re-checked 2026-11-02: no issue or PR matches 'ByteBuffersDirectory rename' or '"unexpectedly replaced"', no PR updated since 2026-10-31 matches ByteBuffersDirectory, the only PR created since 2026-10-31 that matches 'rename' is #16960 (a dependency bump), none of the 20 open PRs updated in the last 48 h changes ByteBuffersDirectory.java, Directory.java, BaseDirectoryTestCase or TestByteBuffersDirectory, and upstream main still throws at ByteBuffersDirectory.java:189 without removing dest (last commit on the file c2825cfc4b2, #16063, author and commit date 2026-05-15). Nearest items, none of them this bug: #8399 (LUCENE-7345, gave RAMDirectory.rename the putIfAbsent plus checked-remove shape; its discussion never mentions the failure branch), #9514 (LUCENE-8468, created ByteBuffersDirectory), #9484 (LUCENE-8438), #9461 (LUCENE-8415, Directory contracts), #7118 (LUCENE-6056) and #6987 (LUCENE-5925, the origin of the rename contract), #9365 (LUCENE-8318, FSDirectory#rename stale pending delete, single-threaded, fixed), #10516 (LUCENE-9477, failed-commit cleanup)
Lucene commit tested
main 5adff78bb168 (the one-test repro through gradle and a standalone JUnit run, assertions on and off, 20 of 20 with 20 iterations; fix A and fix A + LFV-836 through every org.apache.lucene.store suite, and fix A through :lucene:core:check -x test, 2026-11-03); the 10.6.0 and 9.12.3 release jars (the same test). The two-thread tests of the hunt and its verifiers ran on main 5adff78bb168 (the cross-model review on ea57360912ed, then re-run on 5adff78bb168) and on releases from 7.5.0 to 10.6.0.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:48 (at 5adff78bb168) (@lucene.experimental), :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-189 with no removal of dest, then the trailing files.remove(source) :191, LFV-836's fix site)
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:121-133 (at 5adff78bb168) (rename contract: dest must not already exist; both names may be visible 'temporarily' in listAll(); dest must show the entire source atomically; 'This method is used by IndexWriter to publish commits'), :185 (copyThroughCreateOutput: 'On failure, deletes the file named dest')
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:1005 (at 5adff78bb168) (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 5adff78bb168) :126, :178-192, :226
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:121-133 (at 5adff78bb168)
Who is affected
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:43-48, (at 5adff78bb168) :158-159, :241-245
  • lucene/MIGRATE.md:1388-1389 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:1005 (at 5adff78bb168)
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:221 (at 5adff78bb168)
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryTestCase.java:116-125 (at 5adff78bb168)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:43-45, (at 5adff78bb168) :121-133, :183-188
  • lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:254-266 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:996-1018 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:181-189 (at 5adff78bb168)
Diagrams
  • lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:178-192 (at 5adff78bb168)

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 two calls' map operations, made in parallel with this doc from the code at 5adff78bb168 and the executed repro; 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.