All findings · Lucene finding · LFV-840 · psdp-persist-sync-failure-leaves-save-file

One failed fsync makes PersistentSnapshotDeletionPolicy's next snapshot() fail on its own leftover save file

After one failed fsync of its save file, PersistentSnapshotDeletionPolicy's next snapshot() or release(IndexCommit) throws FileAlreadyExistsException with no fault at all, because the failed call left snapshots_0 on disk and the policy reuses that name. Opt-in policy, every release since 6.2.0.

Summary

Priority
Low, rank 1086In 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
wrong error
Since
6.2.0 (root cause from 4.4.0: commit 8c6e38ee605, LUCENE-4976, 2013-05-05)introduced 2013-05-05 · over 13 years
Affected
6.2.0-10.6.0 and main
Status
documented
Found by
non-formal: code reading under a single-fault schedule, then an executed JUnit test

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: persistent snapshots

  1. PersistentSnapshotDeletionPolicy (opt-in, @lucene.experimental) lets you hold snapshots: while one is held, IndexWriter keeps 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.
  2. Every snapshot() or release() writes the whole list to a new save file snapshots_N, fsyncs it, deletes snapshots_(N-1) and adds 1 to N. Lucene files are write-once: creating a file that already exists throws FileAlreadyExistsException.
Summary card: after one failed fsync, PersistentSnapshotDeletionPolicy's next snapshot() throws FileAlreadyExistsException. Four steps: 1, call 1, snapshot(): persist() writes the complete snapshots_0, then dir.sync throws (an injected fsync failure); 2, the cleanup covers only the write, sync is outside the try, so snapshots_0 stays and nextWriteGen stays 0; 3, snapshot() undoes in memory (super.release(ic), refCounts empty) but not on disk: snapshots_0 still holds the snapshot (gen 1, 1 ref); 4, call 2, no fault: same name snapshots_0, createOutput must refuse an existing file and throws FileAlreadyExistsException. Expected: call 2 works and saves snapshots_0. Actual: java.nio.file.FileAlreadyExistsException: File already exists: snapshots_0. Call 3 works again since 9.1.0 (6.2.0-9.0.0: every later call fails until the policy is re-created); release(IndexCommit) fails the same way. Contrast: IndexWriter.commit() retries cleanly after the same fault, because SegmentInfos.write keeps sync inside its cleanup. Executed on main 645d4fe14a20 with -ea and -da; with dir.sync inside the try the repro passes.
Call 1's fsync fails: the cleanup covers only the write, so 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.

The finding, step by step

  1. Open an IndexWriter with PersistentSnapshotDeletionPolicy and commit once. Each snapshot() saves the snapshot list to a new numbered file, here snapshots_0, fsyncs it, and only then moves on to number 1.
  2. Call snapshot() while the fsync fails once (a storage error, too many open files, or a wrapping Directory that throws). It throws IOException and undoes itself: the policy holds 0 snapshots.
  3. Call snapshot() again, with no fault. You expect it to work, as a retried IndexWriter.commit() works after the same fault.
  4. 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, on snapshots_1.
  5. 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() or release(IndexCommit) fails until the policy is re-created.

Who is affected

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.

Terms

  • Save file: snapshots_N, a small file holding the commit generations that are snapshotted and their reference counts. nextWriteGen is the N the next save will use.
  • Write-once: Directory.createOutput "must throw FileAlreadyExistsException if the file already exists" (Directory.java:86); FSDirectory.createOutput returns new FSIndexOutput(name) (FSDirectory.java:215), which opens the file with WRITE, CREATE_NEW (:381).

The bug

  1. persist() (PersistentSnapshotDeletionPolicy.java:161-184) creates and writes snapshots_<nextWriteGen> inside a try whose catch deletes the file (:163-173).
  2. dir.sync(Collections.singletonList(fileName)) comes after that block (:175); then the previous save file is deleted (:177-181) and nextWriteGen++ runs (:183). If the sync throws, neither happens and the new file, already written and closed, stays.
  3. The callers restore their reference counts: snapshot() calls super.release(ic) (:119), release(IndexCommit) calls incRef(commit) (:141), and both rethrow. release(long) has no undo (:156-159): repeating it for the same generation throws IllegalArgumentException before persist().
  4. The next persist() uses the same name; createOutput (:163) throws FileAlreadyExistsException.
  5. Since #589 (9.1.0) createOutput is inside the try, so that exception reaches the catch, which deletes the name, the leftover (:171). The call after it works if that delete succeeds. On 6.2.0-9.0.0 createOutput was called before the try, so every later save failed.
  6. A real sync can fail anywhere in FSDirectory.sync (FSDirectory.java:244-251): in ensureOpen(), in IOUtils.fsync (reached through FSDirectory.fsync, :295-296), which opens the file and forces it (IOUtils.java:534-537), or in maybeDeletePendingFiles(); 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.

The numbers

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 1

After 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.experimental PersistentSnapshotDeletionPolicy (PersistentSnapshotDeletionPolicy.java:47); no other Lucene class uses it outside tests.
  • What it takes. One IOException from Directory.sync of the new save file: an fsync error from the storage, a failure to open the file for fsync (too many open files), or a wrapping Directory whose sync throws. Then a further call that saves the list in the same process, typically a retry: snapshot(), release(IndexCommit), or release(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, incRef maps that generation to the latest commit object (SnapshotDeletionPolicy.java:115-126), so getIndexCommit(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 a release(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.

Minimal repro

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.

TestPersistentSnapshotSyncFailure.java (JUnit, one test)61 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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);
        }
      }
    }
  }
}
Command
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.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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 failed

Why it looks unintended

  • snapshot() and release(IndexCommit) restore their reference counts in memory, and persist() 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.sync promises 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() and release(IndexCommit) restore their reference counts on any persist() failure (PersistentSnapshotDeletionPolicy.java:119, :141; release(long) has no undo), and persist() deletes the new file when creating or writing it fails (:170-173). Only dir.sync (:175) is outside that cleanup.
  • Tests. TestPersistentSnapshotDeletionPolicy.testExceptionDuringSave (:113-148) fails the first call inside persist() and asserts assertEquals(0, psdp.getSnapshotCount()); that first call is createOutput, so the sync branch never runs. testExistingSnapshots asserts "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), and directory.sync (:620) sits inside the try whose catch deletes the new file (:622-628). IndexWriter.startCommit rolls 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 in sync, then asserts that the writer still commits all 23 documents. A contrast test with the same one-shot fault on pending_segments_N leaves 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 later 8c6e38ee605 ("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() or release(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").

Suggested fix

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.

A. Sync inside the delete-on-failure block (validated by the checks) (recommended)

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 before persist() and has no undo.
  • If LFV-194's dir.syncMetaData() is added after the sync, it belongs inside the same try, or a failing syncMetaData() 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.

B. Always advance the generation (validated by the checks)

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 good snapshot()), 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, which testExistingSnapshots forbids 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.

C. A and B together

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.

Downstream note

Solr's SolrSnapshotMetaDataManager and the Lucene.NET port have the same persist() shape (read, not run), so they may need the same change.

How it was found

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.

Prior reports, history and checks

Prior reports

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): same persist() 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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits2026-10-28 and 2026-11-01three 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 listno report; closest #16482, #6040, #7233, #8399, #14334
Open and recently closed PRs2026-10-28 and 2026-11-01the 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-28only #16482 touches the class; it leaves persist() unchanged
Mailing lists, JIRA, downstream projects, web2026-10-28dev@, 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 queriesno report; the Solr and Lucene.NET ports have the same code shape

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (through gradle and standalone); passes with fix A
10.6.0 affectedminimal repro fails with assertions on and off (release jars, checksums verified)
9.1.0, 9.12.3, 10.5.2 third call worksthe 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.0the 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 checkthe 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 onlythe retry works, but a re-loaded policy adopts the leftover
4.3.1 not affectedbefore the single save file; the snapshot retry works

Independent checks

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).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate5-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: hardeningReproduced with its own tests; agreed on root cause and fix; its argument moved the restart effects to a secondary observation.
independent reproducer realOwn 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 onlyOwn FSDirectory test fails 2/3 with two seeds; the fix passes; root cause and fix agreed.
intended-behaviour devil's advocate real, mediumContrast test: a commit retries cleanly after the same fault; rebutted six intended-behaviour arguments.
adjudication confirmed, medium, LowRe-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 confirmedRe-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 passedAn 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,385 words

Finding record

LFV-840, snapshot of 2026-11-06

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 -ea and -da; fix A in a scratch worktree: the repro with assertions on and off, TestPersistentSnapshotDeletionPolicy and TestSnapshotDeletionPolicy with 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 -ea and -da); the hunt's JUnit classes on main a9779ef3c193, 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 main 2f424fd428da (2026-10-30) has no newer commit on the file (read).

Lucene and upstream sources

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 name
  • lucene/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

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

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.