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.
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.- Two threads share one
ByteBuffersDirectoryand hand files over by name: thread W createsa(createOutput, write,close()), thread R publishes it withrename("a", "b1"), then"b2", and so on, and W creates the nextaas soon as the name is free. Nobody deletes anything. - You expect every
awhoseclose()returned to stay until someone renames it: either W'screateOutput("a")fails withFileAlreadyExistsExceptionbecause the oldais still there, or the newasurvives. OnFSDirectorythe same two-thread programs keep every file. - Instead some new
afiles vanish with no error anywhere:listAll()lacks them andopenInputthrowsNoSuchFileException. One stress run lost 88 of 100,001 hand-offs. - Why:
renamemakes four map calls,get("a"),putIfAbsent("b1", file),remove("a", file), and thenremove("a")once more. After the third callais already free, so the fourth can only delete a different file: W's newa, if it was created in between. - The one-test repro puts the new
aexactly 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.
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.
ByteBuffersDirectory: Lucene's heap-residentDirectory. Every file is an entry in oneConcurrentHashMap<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), elseFileAlreadyExistsException(:157-160).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 (Directory.java:125-126).if (!files.remove(source, file)) throw new IllegalStateException("File was unexpectedly replaced: " + source)(:188-190).FileEntryhas noequals(:226), so this removes exactly the moved entry; once it returns,sourceis free.files.remove(source);(:191). Alone it removes nothing. If another thread put a new entry undersourceafter step 3, for example withcreateOutput(source)(:158) orrename(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
ByteBuffersDirectoryfrom 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 aroundrenameandcreateOutput. No setting, index size or data shape is needed; whether it hits depends only on timing. - Opt-in.
ByteBuffersDirectoryis not the default and is@lucene.experimental(ByteBuffersDirectory.java:48); its javadoc saysMMapDirectory"is nearly always a better choice" (:43-46), andMIGRATE.mdcalls it "discouraged in favor of the defaultMMapDirectory". Wrappers such asFilterDirectoryandTrackingDirectoryWrapperdelegaterenamewithout serializing it againstcreateOutput. - Lucene's own paths are not known to be affected. IndexWriter's only rename is
pending_segments_Ntosegments_N(SegmentInfos.java:1002-1005), and nothing re-creates that pending name.NRTCachingDirectorykeeps aByteBuffersDirectorycache (NRTCachingDirectory.java:72) but itsrenameun-caches the source and renames on the delegate (:181-186). - Why tests never saw it.
MockDirectoryWrapper, which wraps most directories thatnewDirectory()gives Lucene's tests (rarely a bare, unsynchronizedRawDirectoryWrapperinstead), declares bothrenameandcreateOutputsynchronized(MockDirectoryWrapper.java:221,:690). - What the caller sees. Nothing at the time; later the file is missing from
listAll()andopenInputthrowsNoSuchFileException. 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.
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.
/*
* 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();
}
}
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 newa, whosecreateOutputandclose()returned, survives the rename of the old one.- Observed
listAll()is[b]: the trailingfiles.remove(source)deleted the newa, and no call reported an error.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2, 9.12.3 and 9.1.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)Directorysays a created file "must only be opened for input (openInput), or deleted (deleteFile)";renamemay only show both names "temporarily".- No order of the two calls loses the file:
createOutput("a")fails withFileAlreadyExistsExceptionor the newasurvives, as onFSDirectory. - 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). Therenamejavadoc 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 throwsFileAlreadyExistsException; if after, the newasurvives. Even a non-atomic copy-then-delete rename keeps the oldaundersourceuntil its one delete, socreateOutput("a")keeps failing until then; with nobody callingdeleteFile, no newacan 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,sourceis already gone, soremove(source)removes nothing without another thread. - History. Before LUCENE-7345,
RAMDirectory.renameFile(renamedrenameby LUCENE-7373 before 6.2.0) wasfileMap.put(dest, file); fileMap.remove(source);, where the final remove was the move itself. LUCENE-7345 (2016, "RAMDirectory now enforces write-once as well";c311eb1b7edon master, branch_6x backport07920c4fdaa) made itputIfAbsentplus the checkedremove(source, file)and kept the old line; LUCENE-8468 (2018) has the same three calls inByteBuffersDirectory. 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: theIllegalStateException("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).
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.
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.*oflucene/core(includingTestByteBuffersDirectoryand itstestRenamefor all three heap variants,TestNRTCachingDirectory,TestFileSwitchDirectory) plus the repro: 29 suites, 777 tests, 0 failures, 17 skipped. TestIndexWriter*,TestDirectoryReader*,TestSegmentInfosandTestPersistentSnapshotDeletionPolicywith-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 synchronizedMockDirectoryWrapper, 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).
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.
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
ConcurrentHashMapsteps ofcreateOutput,deleteFileandrenamefor two threads in TLA+ (BBDir.tla) with the invariantNoLostFile: 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 concurrentrename(tmp, "a")), and the same programs onFSDirectorykept 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream projects; lists; JIRA; web | 2026-10-28 | three 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-8735 | only the related items above |
| Open PRs | 2026-10-28 | the changed files of all 411 open apache/lucene PRs and of the 66 updated since 2026-10-24 | only #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 history | nothing new; upstream main 2f424fd428da still has the line; only #16931 and #16145 touch store/, other files |
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).
| 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 | stress 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 | affected | stress test fails |
| 9.1.0, 9.12.3, 10.5.2, 10.6.0 | affected | the one-test repro fails |
| main 645d4fe14a20 | affected | the 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 line | 6.1.0 passes 10 of 10, 6.2.0 fails 10 of 10 (LUCENE-7345) |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | TLC 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), procedural | Could not execute; mechanism "mechanically plausible"; its one point (no linearizability promise) is answered in the devil's-advocate check. |
| Independent reproducer | real | Own 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, keep | Fresh 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 | verified | The 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. |
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 493 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,382 words
- 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 theorg.apache.lucene.storesuites, the IndexWriter suites onByteBuffersDirectoryand: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 maina9779ef3c193, where every cited file is identical, and on releases from 7.5.0 to 10.5.2.
- 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, :226lucene/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-186lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:221, (at 645d4fe14a20) :690lucene/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-133lucene/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
- TestByteBuffersDirectoryRenameDropsNewSource.java (one test, lucene/core):
repro/LFV-836/src/org/apache/lucene/store/TestByteBuffersDirectoryRenameDropsNewSource.java(52 lines)
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.
assets/LFV-836/bug.png(493 KB)
![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.](../assets/LFV-836/bug.png)