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.
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.- A
ByteBuffersDirectoryholds one file,a. Two threads rename it at the same time: thread 1 callsrename("a", "x"), thread 2 callsrename("a", "y"). - 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
NoSuchFileExceptionand only the winner's name remains; onNIOFSDirectory(this JDK and platform) the same two threads kept one name in 20,000 of 20,000 rounds. renamefirst publishes the file under the new name, then removesaonly ifastill holds that file, else it throws. Both calls publish (xandydiffer), and only one removal ofacan succeed.- Instead the loser throws
IllegalStateException: File was unexpectedly replaced: aand 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. - The fix is one line:
files.remove(dest, file)before the throw.
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.
ByteBuffersDirectory: Lucene's heap-residentDirectory. Every file is an entry in oneConcurrentHashMap<String, FileEntry> files(ByteBuffersDirectory.java:93);listAll()lists its keys (:126).FileEntryhas noequals(: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.
FileEntry file = files.get(source)(:181).files.putIfAbsent(dest, file)(:185): the file is now visible under both names, which therenamejavadoc allows "temporarily" (Directory.java:125-126).if (!files.remove(source, file)) throw new IllegalStateException("File was unexpectedly replaced: " + source)(:188-189). On this path nothing removesdest.
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
ByteBuffersDirectoryfrom another thread while arenameof it runs. The executed case is tworenamecalls with the same source, for example workers that claim a job file by renaming it and expect one winner; arenameracing withdeleteFilepluscreateOutputof its source leavesdestbehind 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.
ByteBuffersDirectoryis not the default and is@lucene.experimental(:48); its javadoc saysMMapDirectory"is nearly always a better choice" (:43-46), andMIGRATE.mdcalls it "discouraged in favor of the defaultMMapDirectory". - What the caller sees. The losing call throws an unchecked
IllegalStateException(renamedeclares onlyIOException) with a message that misdescribes the case. Everyone else sees the file under a name whose rename failed, inlistAll()and, for a closed file as here, with its complete, correct content throughopenInput(by reading; a file still open for writing makesopenInputthrowAccessDeniedException,:241-245), until someone deletes it; a latercreateOutput("y")getsFileAlreadyExistsException(: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_Ntosegments_N(SegmentInfos.java:1005), under the write lock, on a name nothing else touches. Most test directories are wrapped inMockDirectoryWrapper, whoserenameissynchronized(MockDirectoryWrapper.java:221), andBaseDirectoryTestCase.testRenameis 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).
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.
/*
* 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();
}
}
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
ystays bound to the file:listAll()is[x, y].
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)Directory.renameallows 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 throwsNoSuchFileException;NIOFSDirectorykeeps one name. - Lucene's own failure convention removes
dest:copyThroughCreateOutputsays "On failure, deletes the file named dest".
The full argument288 words
- The contract. "Renames
sourcefile todestfile wheredestmust not already exist"; "It is permitted for this operation to not be truly atomic, for example bothsourceanddestcan be visible temporarily inlistAll()" (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 secondrename("a", ...)finds noaand throwsNoSuchFileException(:181-183); the cross-model review executed this control.FSDirectory.renamemoves the file with oneFiles.move(..., ATOMIC_MOVE)(FSDirectory.java:265, after its pending-delete checks), and the devil's-advocate verifier's two-thread program onNIOFSDirectorykept exactly one name in 20,000 of 20,000 rounds (behaviour on this JDK and platform, not a documentedDirectoryrequirement). - Lucene removes
destwhen an operation fails.copyThroughCreateOutput: "On failure, deletes the file nameddest" (Directory.java:184-185).SegmentInfos.finishCommitdeletesdestitself when the step after a successful rename fails (:1006-1013); whenrenameitself throws, its catch deletes onlypending_segments_N(:1014-1017) and does not clean updest. - The check exists to detect interference on
source. The identity-checked remove at:188notices thatsourceno longer holds the file, and the author chose to fail; a half-publisheddestgoes 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 keepdest.
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.
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.
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.*oflucene/coreplus the repro (includingTestByteBuffersDirectory, 171 tests with 6 skipped by assumptions, andTestMultiByteBuffersDirectory): 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.
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.
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 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.
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
renameagainstDirectory.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 atByteBuffersDirectory.java:188-189throws afterputIfAbsent(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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr, Lucene.NET; dev@, java-user@, issues@, commits@; JIRA; web | 2026-11-02 | the scripted prior-report search with 53 queries (2,675 distinct hits) and about 35 manual GitHub searches | only the related items above; "unexpectedly replaced" appears only in the commit mails that carry the code |
| Open PRs | 2026-11-02 | the changed files of every open apache/lucene PR | only #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 history | nothing new; upstream main 1c2072f0284 still throws at :189 without removing dest |
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).
| Version or commit | Verdict | Result |
|---|---|---|
| 7.4.0 and earlier | no such class | ByteBuffersDirectory does not exist (compile error) |
| 7.5.0 | first: 7.5.0 | the two-thread test fails, assertions on and off |
| 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0 | affected | the two-thread test fails |
| 9.12.3, 10.6.0 | affected | the two-thread test and the one-test repro fail |
| main 5adff78bb168 | affected | the 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 shape | the same leftover name (LUCENE-7345); 6.1.0 also ends with two names but both calls succeed, an older, different behaviour |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Two-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 | real | Own 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, Low | The 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, Low | Fresh 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 validated | Gradle on 5adff78bb168 (fail, on and off); standalone 20 of 20, 10.6.0, 9.12.3 (fail); both fixes through the store suites. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 514 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,279 words
- 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 everyorg.apache.lucene.storesuite, 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 main5adff78bb168(the cross-model review onea57360912ed, then re-run on5adff78bb168) and on releases from 7.5.0 to 10.6.0.
- 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, :226lucene/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-245lucene/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-188lucene/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)
- TestByteBuffersDirectoryFailedRenameLeavesDest.java (one test, lucene/core):
repro/LFV-1158/TestByteBuffersDirectoryFailedRenameLeavesDest.java(56 lines)
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.
assets/LFV-1158/bug.png(514 KB)
![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.](../assets/LFV-1158/bug.png)