persist() deletes its new save file when writing it fails, but not when the fsync after the write fails, and it moves on to the next file number only on success.
PersistentSnapshotDeletionPolicy(opt-in,@lucene.experimental) lets you hold snapshots: while one is held,IndexWriterkeeps that commit's files even as the index changes. It also saves the list of held snapshots in the index directory, so they survive a restart.- Every
snapshot()orrelease()writes the whole list to a new save filesnapshots_N, fsyncs it, deletessnapshots_(N-1)and adds 1 to N. Lucene files are write-once: creating a file that already exists throwsFileAlreadyExistsException.
snapshots_0 stays on disk and the next file number stays 0, while the snapshot is undone in memory. Call 2, with no fault, collides with that file. Select the still to open it at full size. Generated with AI, checked against the code.- Open an
IndexWriterwithPersistentSnapshotDeletionPolicyand commit once. Eachsnapshot()saves the snapshot list to a new numbered file, heresnapshots_0, fsyncs it, and only then moves on to number 1. - Call
snapshot()while the fsync fails once (a storage error, too many open files, or a wrappingDirectorythat throws). It throwsIOExceptionand undoes itself: the policy holds 0 snapshots. - Call
snapshot()again, with no fault. You expect it to work, as a retriedIndexWriter.commit()works after the same fault. - Instead it throws
FileAlreadyExistsException: File already exists: snapshots_0: the file written in step 2 is still on disk, and the policy still writes number 0.release(IndexCommit)fails the same way, onsnapshots_1. - This failure's cleanup deletes the leftover, so the third call normally works on 9.1.0 and later; on 6.2.0-9.0.0 every later
snapshot()orrelease(IndexCommit)fails until the policy is re-created.
Only applications that opt into PersistentSnapshotDeletionPolicy, and only after Directory.sync of the save file throws and they call snapshot() or release(IndexCommit) again in the same process. The next call fails although nothing went wrong in it; while the process runs, no index data is lost. Every release from 6.2.0 to 10.6.0, and main.
Mechanism, terms and numbers588 words
PersistentSnapshotDeletionPolicy.persist() writes the snapshot list to snapshots_<nextWriteGen> inside a try whose catch deletes that file, then calls dir.sync(...) after that block and increments nextWriteGen only at the end. When the sync throws, the new file, already written and closed, stays in the directory and the generation is not advanced, while snapshot() and release(IndexCommit) restore their reference counts in memory and rethrow. The next persist() creates the same file name, which a Directory must refuse, so the next fault-free snapshot() or release(IndexCommit) throws FileAlreadyExistsException. Since 9.1.0 that call's cleanup deletes the leftover and the call after it normally works; on 6.2.0-9.0.0 every later save fails until the policy is re-created. While the process runs, no index data is lost. Lucene's commit path handles the same fault: SegmentInfos.write keeps the sync inside its delete-on-failure block and always advances its generation. Moving the sync into the existing try fixes it. Secondary, contract open: a policy re-opened before the next call adopts the leftover, so a snapshot() that threw is in effect and a release(IndexCommit) that threw has taken effect.
- Save file:
snapshots_N, a small file holding the commit generations that are snapshotted and their reference counts.nextWriteGenis the N the next save will use. - Write-once:
Directory.createOutput"must throwFileAlreadyExistsExceptionif the file already exists" (Directory.java:86);FSDirectory.createOutputreturnsnew FSIndexOutput(name)(FSDirectory.java:215), which opens the file withWRITE, CREATE_NEW(:381).
persist()(PersistentSnapshotDeletionPolicy.java:161-184) creates and writessnapshots_<nextWriteGen>inside atrywhosecatchdeletes the file (:163-173).dir.sync(Collections.singletonList(fileName))comes after that block (:175); then the previous save file is deleted (:177-181) andnextWriteGen++runs (:183). If the sync throws, neither happens and the new file, already written and closed, stays.- The callers restore their reference counts:
snapshot()callssuper.release(ic)(:119),release(IndexCommit)callsincRef(commit)(:141), and both rethrow.release(long)has no undo (:156-159): repeating it for the same generation throwsIllegalArgumentExceptionbeforepersist(). - The next
persist()uses the same name;createOutput(:163) throwsFileAlreadyExistsException. - Since #589 (9.1.0)
createOutputis inside thetry, so that exception reaches thecatch, which deletes the name, the leftover (:171). The call after it works if that delete succeeds. On 6.2.0-9.0.0createOutputwas called before thetry, so every later save failed. - A real sync can fail anywhere in
FSDirectory.sync(FSDirectory.java:244-251): inensureOpen(), inIOUtils.fsync(reached throughFSDirectory.fsync,:295-296), which opens the file and forces it (IOUtils.java:534-537), or inmaybeDeletePendingFiles(); for example too many open files, or an I/O error from the storage. The executed runs inject the fault in a wrapper that throws before delegating.
What the checks observed on main (645d4fe14a20, and a9779ef3c193 with the same code): a FilterDirectory whose next sync() throws once, an IndexWriter that commits an empty new index once:
snapshot() #1 sync of snapshots_0 throws -> IOException; snapshot count 0, getLastSaveFile() null
directory still holds snapshots_0
snapshot() #2 no fault -> FileAlreadyExistsException: File already exists: snapshots_0
snapshot() #3 no fault -> works, snapshot count 1After call 1 the directory lists [segments_1, snapshots_0]; with FSDirectory it lists [segments_1, snapshots_0, write.lock]. release(IndexCommit) fails the same way on snapshots_1; after its failed call snapshots_0 and snapshots_1 are both on disk. Nothing outside the policy removes the leftover: IndexFileDeleter handles only codec files, segments_N and pending_segments_N. Assertions on or off make no difference.
Exposure and likelihood319 words
- Who is hit. Applications using the opt-in,
@lucene.experimentalPersistentSnapshotDeletionPolicy(PersistentSnapshotDeletionPolicy.java:47); no other Lucene class uses it outside tests. - What it takes. One
IOExceptionfromDirectory.syncof the new save file: an fsync error from the storage, a failure to open the file for fsync (too many open files), or a wrappingDirectorywhosesyncthrows. Then a further call that saves the list in the same process, typically a retry:snapshot(),release(IndexCommit), orrelease(long)of a generation that is still snapshotted. No scale or concurrency is needed; fsync errors are uncommon (likelihood rare). - What happens. The next call fails with an exception that names a file the caller never created. On 9.1.0 and later the call after that normally works; on 6.2.0-9.0.0 every later save fails until the policy is re-created. A later commit or
IndexWriter.deleteUnusedFiles()does not remove the leftover (IndexFileDeleter.java:142-148,:461-475). - What does not happen. While the process runs, no index data is lost or corrupted, and the reference counts in memory, which decide what the writer keeps, are restored. One detail, read and not run: after a failed
release(IndexCommit)of the last reference to an older commit,incRefmaps that generation to the latest commit object (SnapshotDeletionPolicy.java:115-126), sogetIndexCommit(gen)can name the wrong commit. - Secondary, contract open. If the policy is re-opened before the next call (for example after a restart), it adopts the leftover as the newest save file: a
snapshot()that threw is in effect (count 1 instead of 0) and arelease(IndexCommit)that threw has taken effect (count 0 instead of 1), so the writer may delete a commit the application still believes protected. The re-opened policy loads the save file with the highest generation and deletes older ones (PersistentSnapshotDeletionPolicy.java:211-263). Reviewers argue, with some weight, that the outcome of a failed fsync is indeterminate, so this is an observation, not the claim.
One JUnit test on the public API: a FilterDirectory over ByteBuffersDirectory whose next sync() throws once, an IndexWriter with PersistentSnapshotDeletionPolicy that commits once, then snapshot() twice. Deterministic, single thread; it fails on main and 10.6.0 with assertions on and off.
/*
* 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.index;
import java.io.IOException;
import java.util.Collection;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestPersistentSnapshotSyncFailure extends LuceneTestCase {
private static class FailOnceSyncDirectory extends FilterDirectory {
boolean failNextSync;
FailOnceSyncDirectory() {
super(new ByteBuffersDirectory());
}
@Override
public void sync(Collection<String> names) throws IOException {
if (failNextSync) {
failNextSync = false;
throw new IOException("injected fsync failure");
}
super.sync(names);
}
}
public void testSnapshotAfterFailedSaveFileSync() throws IOException {
try (FailOnceSyncDirectory dir = new FailOnceSyncDirectory()) {
PersistentSnapshotDeletionPolicy policy =
new PersistentSnapshotDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy(), dir);
try (IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setIndexDeletionPolicy(policy))) {
w.commit();
dir.failNextSync = true;
expectThrows(IOException.class, policy::snapshot);
try {
policy.snapshot();
} catch (IOException e) {
fail("expected the next, fault-free snapshot() to succeed, but it threw " + e);
}
}
}
}
}
cp TestPersistentSnapshotSyncFailure.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestPersistentSnapshotSyncFailure -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
- The second, fault-free
snapshot()succeeds, since the first one was undone. - Observed
FileAlreadyExistsException: File already exists: snapshots_0, the file the failed call left behind.
More files, environment, full output184 words
After failNextSync = true no commit runs, so the only sync() before the first snapshot() returns is persist()'s sync of snapshots_0. The wrapper throws before delegating, so the sync has no effect (the conservative case). ByteBuffersDirectory is write-once like FSDirectory; the commit of an empty new index needs no document. The test sits in the index package only to match Lucene's layout and uses no package-private member.
As the regression test: the same test, for example as a method in TestPersistentSnapshotDeletionPolicy.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on the 10.6.0 release; the same scenario fails on every release tested from 6.2.0 to 10.5.2 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestPersistentSnapshotSyncFailure.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestPersistentSnapshotSyncFailure -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestPersistentSnapshotSyncFailure > testSnapshotAfterFailedSaveFileSync FAILED
java.lang.AssertionError: expected the next, fault-free snapshot() to succeed, but it threw java.nio.file.FileAlreadyExistsException: File already exists: snapshots_0
at __randomizedtesting.SeedInfo.seed([DEADBEEF:2F8125E02E33202F]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.apache.lucene.index.TestPersistentSnapshotSyncFailure.testSnapshotAfterFailedSaveFileSync(TestPersistentSnapshotSyncFailure.java:56)
1 test completed, 1 failedsnapshot()andrelease(IndexCommit)restore their reference counts in memory, andpersist()deletes the new file if writing it fails; only the fsync is outside that cleanup.- Lucene's commit file keeps its sync inside the cleanup and always advances its file number, so a retried
commit()works. - The second exception names a file Lucene itself left behind.
The full argument352 words
- What the case rests on. No javadoc line promises that a call after a failed one works, and
Directory.syncpromises nothing when it throws. The case rests on consistency with the class's own cleanup and with Lucene's commit path, not on a documented contract. - The class's own failure handling.
snapshot()andrelease(IndexCommit)restore their reference counts on anypersist()failure (PersistentSnapshotDeletionPolicy.java:119,:141;release(long)has no undo), andpersist()deletes the new file when creating or writing it fails (:170-173). Onlydir.sync(:175) is outside that cleanup. - Tests.
TestPersistentSnapshotDeletionPolicy.testExceptionDuringSave(:113-148) fails the first call insidepersist()and assertsassertEquals(0, psdp.getSnapshotCount()); that first call iscreateOutput, so the sync branch never runs.testExistingSnapshotsasserts "Make sure only 1 save file exists" (:57-64), fault-free. No test asserts the current behaviour after a sync failure. - Lucene's commit path.
SegmentInfos.write(SegmentInfos.java:605-630): "Always advance the generation on write" (:611), anddirectory.sync(:620) sits inside thetrywhosecatchdeletes the new file (:622-628).IndexWriter.startCommitrolls the pending commit back when sync fails (IndexWriter.java:5991-5998) and records the generations "on error or success so we don't double-write a segments_N file" (:6025-6029).TestIndexWriterExceptions.testExceptionDuringSync(LUCENE-1044,:942-972) makes 11 commits fail insync, then asserts that the writer still commits all 23 documents. A contrast test with the same one-shot fault onpending_segments_Nleaves no file and the retried commit works. - History. LUCENE-4976 (#6040) added the single save file with the in-memory undo and the delete-on-failure (
efeaac746ae, 2013-05-03). Two days later8c6e38ee605("add missing sync / delete old save files", by mikemccand) appended the sync after the cleanup block; the issue thread never mentions a sync failure. #589 (2022) and #14913 (2025) refactored the exception handling and kept it there. - Javadoc. Snapshots "are committed as soon as
snapshot()orrelease(IndexCommit)is called" (PersistentSnapshotDeletionPolicy.java:35-37); "Once this method returns, the snapshot information is persisted in the directory" (:107-108,:129-130,:150-151). Nothing says a failed call may break the next one.
The devil's-advocate check232 words
The strongest case, from two refute reviews by an independent reviewer from another model family (OpenAI GPT-5.6), both of which reproduced the failure and agreed on the root cause and the fix: the methods declare IOException and promise persistence only on normal return; Directory.sync has no transactional postcondition when it throws; after an fsync error the file may exist and its durable status is indeterminate, so deleting it "chooses a new rollback policy" and "can discard a record that may already be durable"; release(long) has no rollback at all; the class is @lucene.experimental. Hence the fix is optional hardening.
Why it fails for the headline: none of this explains why a later call, with no fault, should fail because Lucene reuses a file name it left behind itself. Lucene treats a sync failure on its own commit file as recoverable and advances the generation precisely to avoid double-writing a name. The class already treats a failed persist() in snapshot() and release(IndexCommit) as not having happened, so deleting the file matches the in-memory state, and fix B deletes nothing. @lucene.experimental marks API stability, not accepted wrong behaviour; open PR #16482 calls its release(long) rollback "consistent error recovery logic". The argument does carry weight for the restart effect, which stays out of the headline. A maintainer may still call the fix hardening (on #14334 dweiss called a silent write-back failure "a fringe case").
Recommended A. Sync inside the delete-on-failure block
Move dir.sync(...) into the try whose catch already deletes the new save file, so a failed sync leaves nothing behind, as SegmentInfos.write does. The repro passes with assertions on and off, the two snapshot test suites pass with two seeds, and :lucene:core:check passes.
String fileName = SNAPSHOTS_PREFIX + nextWriteGen;
try {
try (IndexOutput out = dir.createOutput(fileName, IOContext.DEFAULT)) {
// header and ref counts, unchanged
}
dir.sync(Collections.singletonList(fileName)); // moved into the try
} catch (Throwable t) {
IOUtils.deleteFilesSuppressingExceptions(t, dir, fileName);
throw t;
}All options and trade-offs3 options ยท 648 words
Option A was run through gradle in a scratch worktree of 645d4fe14a20 and is recommended. B is the alternative the adjudication suggested; it was run against the repro and the snapshot suites. C was not run. Open PR #16482 touches the class but not persist(). A related finding (LFV-194, a missing syncMetaData() after this sync) changes the same lines: a combined fix should put both calls inside the cleanup.
Wrap the existing try-with-resources and the dir.sync call in one try, keeping the existing catch that deletes fileName (+11/-8, mostly re-indentation). A failed sync then removes the new file, as a failed write already does, so for snapshot() and release(IndexCommit) disk matches the in-memory undo and the next call reuses the name safely. It also removes the restart effect: a re-loaded policy finds the previous save file. Both hold when the delete succeeds and the process does not crash between the failed sync and the delete.
String fileName = SNAPSHOTS_PREFIX + nextWriteGen;
try {
try (IndexOutput out = dir.createOutput(fileName, IOContext.DEFAULT)) {
// header and ref counts, unchanged
}
dir.sync(Collections.singletonList(fileName)); // moved into the try
} catch (Throwable t) {
IOUtils.deleteFilesSuppressingExceptions(t, dir, fileName);
throw t;
}Trade-offs
- Error path only: no format, API or performance change.
- If the delete itself fails (it is suppressed), the leftover stays and the next call fails once, then cleans up, as today on 9.1.0 and later.
- The reviewers' worry about deleting a file that may already be durable weighs little here: the caller was told the call failed and memory was rolled back, so the file records a state nobody holds.
- It does not make every failed save a no-op:
release(long)still changes memory beforepersist()and has no undo. - If LFV-194's
dir.syncMetaData()is added after the sync, it belongs inside the sametry, or a failingsyncMetaData()leaves the same leftover.
What the checks validated
Gradle in a scratch worktree of 645d4fe14a20: the minimal repro fails stock and passes with this change, with assertions on and off; TestPersistentSnapshotDeletionPolicy and TestSnapshotDeletionPolicy pass 20 of 20 tests with two seeds; ./gradlew :lucene:core:check -x test (format, ecjLint, forbiddenApis, including the added test) passes. The hunt's 5-test class, including the two restart tests, passes 5 of 5 with the same change. The fact check's probe with it re-loads counts 0 and 1, so the restart effect is gone. The full core suite was not run.
Take long gen = nextWriteGen; nextWriteGen++; at the top of persist() and use gen for the file name and for the delete of snapshots_(gen-1), like SegmentInfos.write ("Always advance the generation on write"). The retry writes snapshots_1 instead of colliding with snapshots_0; nothing is deleted on failure.
Trade-offs
- The failed call's file stays, so the restart effect remains: a re-loaded policy adopts it.
- After a failure
getLastSaveFile()names the failed file, which may not exist. - When an earlier successful save file exists (for example a failed
release(IndexCommit)after a goodsnapshot()), the next successful save deletes only the failed name, so the last good file lingers until the policy is re-loaded: two save files on disk, whichtestExistingSnapshotsforbids in the fault-free case.
What the checks validated
Gradle in the same worktree: the minimal repro passes (assertions on) and the two snapshot suites pass 20 of 20 tests. The fact check then ran all three trade-offs with its own probe: after a failed snapshot(), getLastSaveFile() is snapshots_0 and a re-loaded policy still counts 1; after a failed release(IndexCommit) and a successful retry, snapshots_0 and snapshots_2 are both on disk.
Advance the generation on every call and delete the new file on any failure: robust even when the delete fails, at the cost of B's getLastSaveFile() and lingering-file questions. Not run.
Solr's SolrSnapshotMetaDataManager and the Lucene.NET port have the same persist() shape (read, not run), so they may need the same change.
Non-formal: a hunt on failed commits followed by crashes read persist() under a single fault, one Directory.sync that throws without effect, and then confirmed it with a public-API JUnit test on the stock main jar. An independent reproducer, a devil's advocate, a refute review and an adjudication re-ran it.
The method in full131 words
Method: non-formal (code reading, then executed tests). The hunt unit mainly enumerated crash states after a failed commit under a single-fault schedule; alongside, it read PersistentSnapshotDeletionPolicy.persist() under the same schedule (one Directory.sync that throws and has no effect) and saw that the sync is outside the cleanup. A 5-test JUnit class on the stock main jar (a FilterDirectory whose sync throws once) failed 4 of 5 with assertions on and off; the no-fault control passed. An earlier hunt unit had listed the mechanism as an unexecuted lead.
Before the finding was registered, an independent reviewer from another model family (OpenAI GPT-5.6) reproduced it with its own tests and argued hardening; that moved the restart effects to a secondary observation. On 2026-10-29 the class was cut to the one test shown here.
No prior report found in GitHub issues and PRs (incl. the JIRA archive), commits, all open PRs by changed files, the mailing lists, downstream projects or the web (searched 2026-10-28, re-checked 2026-11-01). Nearest: open PR #16482, which adds rollback to release(long) but leaves persist() unchanged.
Searches3 searches
Closest items, none a report of this bug:
- #16482 (gsmiller, open, labelled Stale, last activity 2026-08-25), "Modernize SnapshotDeletionPolicy and PersistentSnapshotDeletionPolicy": adds rollback to
release(long)and makes the loader read only the newest file;persist()is unchanged. Its review comment by epotyom is about a corrupt newest file left when the write and its cleanup both fail (a different bug, LFV-199), not a failed sync. - #6040 (LUCENE-4976): the introducing issue; no failure discussion.
- #7233 (LUCENE-6171) and #8399 (LUCENE-7345): the write-once changes in 6.2.0 that expose the leftover.
- #14334: fsync semantics. Of a write-back error that is never reported after the file was closed, dweiss wrote that it "seems like a fringe case that - even if possible - falls under the tiny, tiny fraction of possibilities that can end up corrupting your index". Context, not a report.
- Solr's
SolrSnapshotMetaDataManager(SOLR-9269, SOLR-16888) and the Lucene.NET port (lucenenet#1293): samepersist()shape (read, not run); no report there.
Limits: GitHub search reads a bounded page per query and does not reliably index review comments, so #16482 was read directly; private reports are not visible. Bounded searches cannot prove that no report exists.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits | 2026-10-28 and 2026-11-01 | three scripted prior-report searches (identifiers, symptoms, mechanism; 16 issue/PR and 3 commit queries each); about 25 manual queries (class and field names, nextWriteGen, getLastSaveFile, SNAPSHOTS_PREFIX, FileAlreadyExistsException phrasings); the file's commit list | no report; closest #16482, #6040, #7233, #8399, #14334 |
| Open and recently closed PRs | 2026-10-28 and 2026-11-01 | the changed files of all 411 open PRs and of the 20 closed since 2026-10-24; on 2026-11-01 the PRs naming the class and those updated since 2026-10-28 | only #16482 touches the class; it leaves persist() unchanged |
| Mailing lists, JIRA, downstream projects, web | 2026-10-28 | dev@, java-user@, issues@, commits@ and the Solr lists (20 queries per scripted search); JIRA LUCENE, SOLR, LUCENENET; Solr, Elasticsearch, OpenSearch and Lucene.NET repositories; 18 web queries | no report; the Solr and Lucene.NET ports have the same code shape |
Root cause from 4.4.0: 8c6e38ee605 (LUCENE-4976, 2013) appended the sync after the cleanup. The failing retry from 6.2.0, when files became write-once. 6.2.0-9.0.0: every later save fails; 9.1.0 and later (#589): one extra failed call. Not fixed upstream.
Versions tested7 rows
Introduced by 8c6e38ee605 "LUCENE-4976: add missing sync / delete old save files" (mikemccand, 2013-05-05; svn era, no PR, issue #6040; backport 1edbfb98ee8). It appended dir.sync(...) after the delete-on-failure block and before nextWriteGen++; first released in 4.4.0. Until 6.1.0 createOutput silently overwrote the leftover, so the retry worked. LUCENE-6171 (#7233) and LUCENE-7345 (#8399), both intended, made FSDirectory and RAMDirectory write-once in 6.2.0, which turned the stale name reuse into FileAlreadyExistsException; 6.2.0 with the 6.1.0 createOutput options passes. #589 (74698994a9b, 2022-01-10, 9.1.0) moved createOutput into the try, so since then the failing retry deletes the leftover. Upstream main 2f424fd428da (2026-10-30) has no commit on the file after #14913 (2025-07-14).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (through gradle and standalone); passes with fix A |
| 10.6.0 | affected | minimal repro fails with assertions on and off (release jars, checksums verified) |
| 9.1.0, 9.12.3, 10.5.2 | third call works | the next call throws FileAlreadyExistsException, the call after it works |
| 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0 | first affected 6.2.0 | the next call and the third call fail (on 9.0.0 also the fourth, in the fact check's probe) |
| 6.2.0 with 6.1.0 createOutput options | causal check | the retry overwrites the leftover and works |
| 4.4.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.1.0 | leftover only | the retry works, but a re-loaded policy adopts the leftover |
| 4.3.1 | not affected | before the single save file; the snapshot retry works |
Confirmed (medium confidence) by an adjudication after an independent reproducer, an intended-behaviour devil's advocate and an executed refute review; all reproduced it and agreed on the root cause and the fix. Both refute reviews called the fix hardening.
Checks and verdicts274 words
Everyone agrees on the mechanism. The open point is whether maintainers see it as a bug or as hardening (kept as Low).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 5-test JUnit: 4 fail on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4; control passes; fix 5/5. |
| cross-model review (OpenAI GPT-5.6), before registration | not real, medium: hardening | Reproduced with its own tests; agreed on root cause and fix; its argument moved the restart effects to a secondary observation. |
| independent reproducer | real | Own JUnit from the claim (FSDirectory, ByteBuffersDirectory): 3/5 fail on main, 10.5.2, 9.12.3; in-memory undo confirmed; fix 5/5; upstream snapshot suite passes stock and fixed. |
| refute review (OpenAI GPT-5.6, executed) | not real, medium: contract argument only | Own FSDirectory test fails 2/3 with two seeds; the fix passes; root cause and fix agreed. |
| intended-behaviour devil's advocate | real, medium | Contrast test: a commit retries cleanly after the same fault; rebutted six intended-behaviour arguments. |
| adjudication | confirmed, medium, Low | Re-ran the reproducer's JUnit with a new seed (3/5 fail, -ea and -da) and with the fix (5/5 pass). |
| Registration check, audit, minimal repro, this doc | confirmed | Re-runs on main with new seeds; the one-test repro fails on main and 10.6.0 and passes with fix A. |
| fact checks of this doc | fixed and passed | An independent check re-ran the repro (standalone and through gradle, -ea and -da), both fixes and its own probe on main, 9.0.0, 9.1.0 and 10.6.0; a cross-model check (OpenAI GPT-5.6) read the code and the recorded runs. Their line, wording and overclaim findings were fixed. |
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 398 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,385 words
- Registry title
- After one failed fsync of its save file, PersistentSnapshotDeletionPolicy's next fault-free snapshot() or release() fails with FileAlreadyExistsException on its own leftover file: persist() leaves the complete snapshots_N behind when Directory.sync throws and does not advance its generation, while snapshot() and release() undo their change in memory
- Area
- index / deletion policies (PersistentSnapshotDeletionPolicy save-file failure handling)
- Priority
- Low, rank 1086: In the opt-in, experimental PersistentSnapshotDeletionPolicy, one failed fsync makes the next snapshot() or release() throw FileAlreadyExistsException, then it recovers on 9.1+ with no data lost.
- Impact class
- wrong error
- Impact note
- Hits only applications on the opt-in, @lucene.experimental PersistentSnapshotDeletionPolicy, after Directory.sync of the new save file throws once (an fsync error such as EIO, a failure to open the file for fsync such as too many open files, or a wrapping Directory that throws): the next snapshot() or release() with no fault throws FileAlreadyExistsException on Lucene's own leftover snapshots_N, and on 9.1.0 and later the call after that works again (on 6.2.0-9.0.0 every later call fails until the policy is re-created); no index data is lost in the process, and no scale is needed.
- Summary
- You call PersistentSnapshotDeletionPolicy.snapshot() (or release()), fsync of the new save file snapshots_N fails once, the call throws and the policy undoes the change in memory; you expect the next call, with no fault, to work. Instead persist() has left the complete snapshots_N in the directory (its delete-on-failure block covers only creating and writing the file, not the sync) and has not advanced its generation, so the next snapshot() or release() writes the same name and fails with FileAlreadyExistsException; that call's cleanup deletes the leftover, and the call after it works. Secondary, contract open: a policy re-opened before that next call adopts the leftover, so a snapshot() that threw is in effect and a release() that threw has taken effect.
- Introduced
- 8c6e38ee605494627a2f29f2de8ca3ba09bef342 (trunk, svn r1479394) and its branch_4x backport 1edbfb98ee8 (svn r1479395), 'LUCENE-4976: add missing sync / delete old save files' by Michael McCandless (svn-era commit, no pull request; issue now apache/lucene#6040, whose comments never discuss a sync failure) [read: it appended dir.sync(...) after persist()'s delete-on-failure block and before nextWriteGen++, so from then on the new save file survives a persist() whose sync throws; release boundary executed: 4.3.1 not affected, 4.4.0 leaves the file]. The headline symptom needs Lucene's write-once createOutput, LUCENE-6171 (a893c645952 / 45d2d2e7d34, #7233) and LUCENE-7345 (07920c4fdaa, #8399), both first released in 6.2.0 and intended, not defects [exec causal check: 6.2.0 with the 6.1.0 createOutput options passes]. #589 (74698994a9b, 2022-01-10, 9.1.0) moved createOutput into the try, so from 9.1.0 the failing retry deletes the leftover and the third call works (2013-05-05 (author date of 8c6e38ee605; first released in 4.4.0, 2013-07))
- First affected version
- 6.2.0 [exec] for the headline (the next fault-free call fails; 6.1.0 passes because its createOutput overwrote the leftover); the leftover file and the secondary re-load effect exist from 4.4.0 [exec] (4.3.1, the last release before LUCENE-4976, is not affected)
- Affected versions
- Headline: every release 6.2.0-10.6.0 and main a9779ef3c193 [exec FAIL: 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2, main with -ea and -da; the one-test minimal repro on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da, 2026-11-01; the finder's JUnit fails 4 of 5 on 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main]; the releases in between by a tag scan of the history clone (the sync is outside the cleanup in every tag 4.4.0-10.5.2 and createOutput is write-once in every tag from 6.2.0; 8.9-8.11.x are not tagged there, 8.11.4 was executed from release jars) [read]. 6.2.0-9.0.0: every later call fails until the policy is re-created (third call executed, later calls by reading); 9.1.0 and later: one extra failed call, then the policy recovers by itself. 4.4.0-6.1.0: headline not affected [exec: 4.4.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.1.0], but a re-loaded policy adopts the leftover. Not fixed upstream: upstream main 20ced71c1e6e, branch_10x, branch_10_6 and branch_10_5 have no commit on the file after #14913 (2025-07-14) and open PR #16482 leaves persist() unchanged [read, 2026-10-28], so the released 10.6.0 has it too. All runs on aarch64 with JDK 25
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (searched 2026-10-28, read-only: three scripted prior-report searches (identifiers, symptoms, mechanism) over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; manual GitHub searches in apache/lucene, Solr, Elasticsearch, OpenSearch and Lucene.NET; the changed files of all 411 open apache/lucene PRs and of the 20 PRs closed since 2026-10-24; JIRA LUCENE, SOLR and LUCENENET; lists.apache.org; the git history; earlier searches by the finder, the independent reproduction, the intended-behaviour check and the adjudication also found nothing); re-checked on 2026-10-28 (none of the 59 PRs updated since 2026-10-26 touches SnapshotDeletionPolicy, FSDirectory.java or IOUtils.java; no new issue; the newest upstream commit on the file is still #14913, 2025-07-14). No issue, PR, review comment, JIRA entry, list thread or web page reports that a failed Directory.sync in persist() leaves the save file behind or that the next call then fails. Nearest items, not this bug: open PR #16482 (gsmiller, labelled Stale, last activity 2026-08-25: adds rollback to release(long) and makes the loader read only the newest file, leaves persist()'s sync outside the cleanup; its review comment by epotyom discusses a corrupt newest file left when the write and its cleanup both fail, which is LFV-199, and does not consider a failed sync); #6040 (LUCENE-4976, introducing, no failure discussion); #14334 (fsync-after-close semantics, where a maintainer calls a write-back error that is never reported after the file was closed a fringe case); #7233 and #8399 (write-once createOutput).
- Lucene commit tested
- main
645d4fe14a20(the minimal repro through gradle with assertions on and off, and standalone with-eaand-da; fix A in a scratch worktree: the repro with assertions on and off,TestPersistentSnapshotDeletionPolicyandTestSnapshotDeletionPolicywith two seeds, and:lucene:core:check -x test; fix B: the repro and the same suites); the 10.6.0 release jars, checksums verified against Maven Central (the minimal repro with-eaand-da); the hunt's JUnit classes on maina9779ef3c193, which has the same code, and on 10.5.2, 9.12.3 and 8.11.4; history ports on 16 releases from 4.3.1 to 10.5.2. Upstream main2f424fd428da(2026-10-30) has no newer commit on the file (read).
- Finding record
lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:35-47 (at a9779ef3c193) (class javadoc: snapshots 'are committed as soon as snapshot() or release(IndexCommit) is called', :37; @lucene.experimental, :47), :105-147 (snapshot(): super.release(ic) on a persist() failure, :119; release(IndexCommit): incRef(commit), :141; 'Once this method returns, the snapshot information is persisted', :107, :129), :156-159 (release(long): no rollback), :161-184 (persist(): createOutput :163, delete-on-failure catch :170-173, dir.sync :175 outside it, nextWriteGen++ :183)lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:605-630 (at a9779ef3c193) (write: 'Always advance the generation on write', :611; directory.sync inside the try whose catch deletes the file, :620, :627); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6025-6029 (generation recorded 'on error or success so we don't double-write a segments_N file'); lucene/core/src/java/org/apache/lucene/store/Directory.java:86 (createOutput must throw FileAlreadyExistsException for an existing file)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:161-184, :113-126, :135-147 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/store/Directory.java:86; store/FSDirectory.java:215, :244-251, :295-296, :381; util/IOUtils.java:534-537 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:156-159; lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java:99-102 (release(long): no undo; IllegalArgumentException on a repeat)lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:142-148, :461-475; index/IndexFileNames.java:199 (files IndexFileDeleter handles)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:47 (@lucene.experimental), :211-263 (loadPriorSnapshots) (at 645d4fe14a20); grep of lucene/**/src/java for the class namelucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:142-148, :461-475; index/SnapshotDeletionPolicy.java:106-108, :115-126, :206-214 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:35-37, :107-108, :119, :129-130, :141, :150-151, :170-175 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:605-630; index/IndexWriter.java:5991-5998, :6025-6029 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/index/TestPersistentSnapshotDeletionPolicy.java:57-64, :113-148; TestIndexWriterExceptions.java:942-972 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:611-628 (at 645d4fe14a20)
- History
a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java
- TestPersistentSnapshotSyncFailure.java (JUnit, one test):
repro/LFV-840/src/org/apache/lucene/index/TestPersistentSnapshotSyncFailure.java(61 lines)
Every value in the diagram comes from a small probe run, the code or the finding record, and each probe value was checked before it was drawn.
assets/LFV-840/bug.png(398 KB)
