All findings · Lucene finding · LFV-194 · psdp-persist-no-directory-sync

PersistentSnapshotDeletionPolicy never fsyncs the directory of its save file, so an OS crash can lose an acknowledged snapshot

PersistentSnapshotDeletionPolicy.snapshot() promises the snapshot is persisted when it returns, but never fsyncs the directory holding the new snapshots_N file, a step Lucene's commit always takes. After an OS crash or power loss the file can vanish: the snapshot count is 0 and the reopened IndexWriter deletes the protected commit.

Summary

Priority
Low, rank 850PersistentSnapshotDeletionPolicy never fsyncs the directory after writing a save file, so a crash on a non-journaling file system can lose an acknowledged snapshot, opt-in and contrived.
Impact
data corruption
Since
5.0.0 (2015, a side effect of LUCENE-5925)introduced 2014-09-11 · over 12 years
Affected
5.0.0 to 10.6.0 and main
Status
documented
Found by
crash-state enumeration of the real jars (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-03.

The finding in 30 seconds

Background

  1. A newer commit normally makes Lucene delete older ones. PersistentSnapshotDeletionPolicy.snapshot() pins the latest commit so it is kept, for example while a backup copies its files, and saves the pins in a small file snapshots_N so they survive a restart.
  2. A new file reaches disk in two parts: its bytes and its directory entry (the name). Lucene's Directory API has one call for each: sync(files) makes the bytes durable, syncMetaData() makes names durable. POSIX does not promise that an fsync of the file also saves its name.
Diagram of the bug: snapshot() writes the save file snapshots_0 and fsyncs its bytes but not its directory entry, because no syncMetaData() follows. After a crash the snapshot directory is empty, getSnapshotCount() is 0 and the reopened IndexWriter deletes the snapshotted commit 1.
How snapshot() makes the save file's bytes durable but not its name, and what a crash leaves behind. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You pin a commit with PersistentSnapshotDeletionPolicy, snapshots in their own Directory: commit 1, then snapshot(), which writes snapshots_0, then commit 2. Both commits stay on disk because commit 1 is pinned.
  2. You expect what the javadoc says: once snapshot() returns, the snapshot is persisted and survives a crash of the machine, as a commit does.
  3. Instead persist() fsyncs the bytes of snapshots_0 but never calls Directory.syncMetaData(), so the file's name (its directory entry) is not yet on disk. Lucene's own commit calls syncMetaData() before and after publishing segments_N for exactly this reason.
  4. The machine crashes before the OS writes that entry. After the restart the snapshot directory is empty, getSnapshotCount() is 0 instead of 1, and the reopened IndexWriter keeps only commit 2: commit 1, the one the snapshot protected, is deleted (shown under a simulated crash built on Lucene's Directory split of sync() and syncMetaData(), not after a real power cut).

Who is affected

Only applications that opt in to PersistentSnapshotDeletionPolicy (usually for backups or replication), after an OS crash, power loss or hard reset shortly after snapshot() or release(), on a file system that does not persist a new name with the file's fsync. ext4, XFS and btrfs are expected to persist it; a JVM crash does not trigger it.

Mechanism, terms and numbers681 words

PersistentSnapshotDeletionPolicy.snapshot(), release(IndexCommit) and release(long) are documented to return only once "the snapshot information is persisted in the directory". They write the new save file snapshots_N and fsync its bytes, but never call Directory.syncMetaData(), the call that makes a new file's name durable and that Lucene's own commit makes twice. After an OS crash or power loss on a file system that does not persist a new name together with the file's fsync, the save file can be gone: with the snapshots in their own Directory, getSnapshotCount() comes back as 0 and the reopened IndexWriter deletes the snapshotted commit; with the snapshots in the index Directory the next commit deletes it; a lost release() brings a released snapshot back. The live index stays consistent. It is a side-effect regression of LUCENE-5925 in 5.0.0, and the fix is one line.

Terms

  • Snapshot: PersistentSnapshotDeletionPolicy (PSDP, @lucene.experimental) wraps another deletion policy and keeps every snapshotted commit until release(). It stores the reference counts per commit generation in a save file snapshots_N in a Directory of your choice: its own, or the index Directory ("It is OK to use the same Directory that holds the index", PersistentSnapshotDeletionPolicy.java:42).
  • Durable name: Directory.sync() "Ensures that any writes to these files are moved to stable storage"; Directory.syncMetaData() "Ensures that directory metadata, such as recent file renames, are moved to stable storage" (Directory.java:104-119). On FSDirectory the first fsyncs each file (FSDirectory.java:243-251), the second fsyncs the directory (:268-274). The Linux fsync(2) man page: "Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk."

The bug

  1. snapshot() (:113-126), release(IndexCommit) (:135-147) and release(long) (:156-159) update the counts and call persist().
  2. persist() (:161-184) creates snapshots_<nextWriteGen> and writes the counts (:162-173), calls dir.sync(Collections.singletonList(fileName)) (:175), deletes the previous save file (:177-181) and returns. Nothing in the class calls syncMetaData(), and nothing ever did (git log -S syncMetaData on the file: no commits).
  3. Lucene's commit does call it: SegmentInfos.prepareCommit() before writing pending_segments_N (SegmentInfos.java:970) and finishCommit() after renaming it to segments_N (:1005-1007), and by the second call every file of the commit, segments_N included, is already synced (IndexWriter.java:5993, SegmentInfos.java:620): commit does not rely on a file's sync to make its name durable.
  4. So, when snapshot() returns, the save file's bytes are durable and its name is not. In the index Directory, the writer's next commit makes the name durable; a separate snapshot Directory is never fsynced by Lucene at all.
  5. On restart loadPriorSnapshots() loads the newest snapshots_N it can list (:211-264). If the newest name was lost it loads nothing (a lost first snapshot) or the previous file (a lost later snapshot, or a lost release()).

The numbers (simulated crash: a crash model built on sync() and syncMetaData())

An independent crash-model test keeps across a crash exactly the names covered by the last syncMetaData() and the bytes of synced files. On main 645d4fe14a20, 3 of 3 tests fail, and its 3 controls pass:

  • Separate snapshot Directory: commit 1, snapshot() (writes snapshots_0), commit 2, crash. After the crash the snapshot directory is [], getSnapshotCount() is 0 instead of 1, and the reopened IndexWriter keeps commits [2] instead of [1, 2].
  • Snapshots in the index Directory: a crash before the next commit loses the snapshot (count 0); the next commit then deletes commit 1 ([2]).
  • release(): snapshots_1 is lost and the not-yet-durable delete of snapshots_0 is undone, so the released snapshot comes back (count 1 instead of 0). This is the less harmful direction: no commit is deleted, but the released commit's files and disk space are kept longer than asked.

On a real FSDirectory, with no crash model, a probe that records every FileChannel.force call sees commit() fsync the index directory (6 forces), while snapshot() and release() force only snapshots_0 and snapshots_1 and no directory, in both setups.

Exposure and likelihood311 words
  • Opt-in. The default IndexWriterConfig uses KeepOnlyLastCommitDeletionPolicy and is not affected. PSDP is @lucene.experimental (PersistentSnapshotDeletionPolicy.java:47).
  • The trigger is a machine-level failure: an OS crash or kernel panic, a power loss, or a hard reset of the machine or VM, after snapshot() or release() returned and before the directory entry reaches disk. With the snapshots in the index Directory that window ends at the writer's next commit; with their own Directory it lasts until the OS writes the directory back.
  • Process death is not a trigger. A probe called Runtime.halt() right after snapshot() or release() returned, with nothing closed, and reopened the index in a new JVM: 10 of 10 acknowledged states survived. kill -9 and JVM crashes are the same case by reasoning.
  • File systems. POSIX and the Linux fsync(2) man page allow a new file's name to be lost although the file was fsynced; Lucene's commit defends against that (LUCENE-5588, LUCENE-8048 "Filesystems do not guarantee order of directories updates"). Mainstream journaling file systems such as ext4, XFS and btrfs are expected to persist the name along with the file's fsync, so the loss is not expected there; this is reasoning, not tested on a real power cut. On Windows Lucene fsyncs no directory at all (IOUtils.java:526-533).
  • What is lost. An acknowledged snapshot record and, with it, the snapshotted commit: deleted when the IndexWriter reopens (separate Directory) or at the next commit (index Directory). A backup or replica still copying that commit loses its source. A lost release() keeps a commit longer than asked. The live index stays consistent and searchable.
  • By reading only (not run): reopening with OpenMode.APPEND after losing the only save file throws IllegalStateException("no snapshots stored in this directory") (:101-103).
  • Scale: none; one document, one commit and one snapshot() suffice. Likelihood: contrived (it needs all of the above at once).

Minimal repro

One JUnit test using public classes only (the counting wrapper extends FilterDirectory, which is public but marked @lucene.internal). A FilterDirectory counts syncMetaData() calls on the snapshot directory, which only the snapshot policy writes to. After one commit and one snapshot() the count is 0. The test checks the cause; the crash itself was simulated separately (see Mechanism).

TestPersistentSnapshotSyncMetaData.java (one JUnit test)54 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.concurrent.atomic.AtomicInteger;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestPersistentSnapshotSyncMetaData extends LuceneTestCase {

  public void testSnapshotSyncsSaveFileName() throws Exception {
    AtomicInteger syncMetaDataCalls = new AtomicInteger();
    try (Directory indexDir = new ByteBuffersDirectory();
        Directory snapshotDir =
            new FilterDirectory(new ByteBuffersDirectory()) {
              @Override
              public void syncMetaData() throws IOException {
                syncMetaDataCalls.incrementAndGet();
                super.syncMetaData();
              }
            }) {
      PersistentSnapshotDeletionPolicy psdp =
          new PersistentSnapshotDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy(), snapshotDir);
      try (IndexWriter w =
          new IndexWriter(indexDir, new IndexWriterConfig().setIndexDeletionPolicy(psdp))) {
        w.addDocument(new Document());
        w.commit();
        psdp.snapshot();
        // expected: snapshot() makes the new snapshots_N name durable (syncMetaData); actual: never
        assertTrue(
            "expected >= 1 syncMetaData() call in snapshot(), got " + syncMetaDataCalls.get(),
            syncMetaDataCalls.get() >= 1);
      }
    }
  }
}
Command
cd $LUCENE
cp $REPRO/TestPersistentSnapshotSyncMetaData.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestPersistentSnapshotSyncMetaData -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
snapshot() calls syncMetaData() on the snapshot directory at least once before it returns, so the name snapshots_0 is durable.
Observed
0 calls: expected >= 1 syncMetaData() call in snapshot(), got 0 (assertions on and off; also on 10.6.0, 10.5.2 and 9.12.3).

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

More files, environment, full output158 words

Both directories are in memory (ByteBuffersDirectory), the test body calls no random() and uses a plain IndexWriterConfig; the test framework's randomized codec, locale and time zone do not touch the syncMetaData() count, so the seed does not change the result. The snapshots live in their own Directory only so that every syncMetaData() call the counter sees can come from the snapshot policy; the javadoc also allows the index Directory. Copy the file into lucene/core/src/test/org/apache/lucene/index/; no build change is needed. It fails with assertions on and off, and unchanged on the 10.6.0, 10.5.2 and 9.12.3 release jars. With the one-line fix it passes.

Regression test: this same test, as written.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2 and 9.12.3
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestPersistentSnapshotSyncMetaData > testSnapshotSyncsSaveFileName FAILED
    java.lang.AssertionError: expected >= 1 syncMetaData() call in snapshot(), got 0
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:D187E000AA5DDBBB]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.index.TestPersistentSnapshotSyncMetaData.testSnapshotSyncsSaveFileName(TestPersistentSnapshotSyncMetaData.java:48)

Why it looks unintended

  • The javadoc: "Once this method returns, the snapshot information is persisted in the directory"; SnapshotDeletionPolicy points here for protection against "system failures".
  • Lucene's own commit calls syncMetaData() twice, the second time after every file is already synced.
  • A regression: until 4.10.4 FSDirectory.sync() also fsynced the directory; LUCENE-5925 (5.0.0) moved that into the segments_N rename.
The full argument263 words
  • The contract. "The snapshots are persisted in a Directory and 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" (:106-108, :128-130, :149-151). SnapshotDeletionPolicy: "This class maintains all snapshots in-memory, and so the information is not persisted and not protected against system failures. If persistence is important, you can use PersistentSnapshotDeletionPolicy" (SnapshotDeletionPolicy.java:35-37).
  • Lucene's own protocol. Directory separates sync() (file writes) from syncMetaData() (directory metadata), and commit calls syncMetaData() twice (SegmentInfos.java:970, :1007), the second time after every file of the commit, segments_N included, is synced.
  • The test intent. TestPersistentSnapshotDeletionPolicy checks // Make sure we fsync: dir.crash(); (:66-67), where MockDirectoryWrapper.crash() "Simulates a crash of OS or machine" (MockDirectoryWrapper.java:512). It cannot see this bug only because crash() damages or deletes only files that were never sync()ed (:356-358, :526) and never drops the name of a sync()ed file, whether or not syncMetaData() was called; its own TODO: "TODO: we should also mess with any recent file renames, file deletions, if syncMetaData was not called!!" (:339-340).
  • The history. The file sync() came in 2013 as a "missing sync" (LUCENE-4976). In 4.8.0-4.10.4 the stock FSDirectory.sync() also fsynced the directory (LUCENE-5588), so the name was durable. LUCENE-5925 (2014, 5.0.0) moved that fsync into the new renameFile(), which only the segments_N publish calls; its discussion never mentions snapshots. PersistentSnapshotDeletionPolicy.java is identical in 4.10.4 and 5.0.0, and LUCENE-7373 (6.2.0, syncMetaData()) did not update it.
The devil's-advocate check139 words

The strongest case: "persisted" only means surviving a JVM crash; the class was created (LUCENE-2481, 2010) "in case the JVM crashes". Also argued: sync() of a new file covers its creation; and the design is deliberate.

Why they fail: SnapshotDeletionPolicy sends users to this class for protection against "system failures"; a file sync() adds nothing against JVM crashes alone (the OS keeps the data when only the process dies, as the Runtime.halt probe shows); the upstream test simulates an OS or machine crash; Lucene's commit does not rely on sync() for names; and no issue, comment or CHANGES entry marks the loss as intended, which came as a side effect of an unrelated change. What remains is an argument about reach (mainstream journaling file systems are expected to keep the name), not about intent. Verdict: not intended, a regression.

Suggested fix

Recommended syncMetaData() right after the file sync in persist()

Call dir.syncMetaData() right after dir.sync(...) in persist(), before the old save file is deleted: one line. The repro and the existing snapshot-policy suites pass with it. Until it lands, applications can call syncMetaData() on the snapshot Directory after snapshot() and release().

     dir.sync(Collections.singletonList(fileName));
+    dir.syncMetaData(); // make the new save file's name durable before deleting the old one
 
     if (nextWriteGen > 0) {
All options and trade-offs4 options ยท 398 words

One fix was built and validated through gradle; the alternatives are listed for completeness.

syncMetaData() right after the file sync in persist() (validated by the checks) (recommended)

Make the new save file's name durable before the old file is deleted. The order is safe: if a crash keeps both files, loadPriorSnapshots() always loads the newest generation (PersistentSnapshotDeletionPolicy.java:218), so the new state wins and the delete need not be durable. It usually deletes the older file too (:253-260), but not when the older name sorts after the newer one in listAll() (string order, e.g. snapshots_9 after snapshots_10), which is harmless.

     dir.sync(Collections.singletonList(fileName));
+    dir.syncMetaData(); // make the new save file's name durable before deleting the old one
 
     if (nextWriteGen > 0) {

Trade-offs

One extra directory fsync per snapshot() or release(), operations that run per backup or replication round (a commit already makes two); no benchmark was run. With the snapshots in the index Directory it fsyncs the index directory, which is harmless. On Windows the directory fsync is skipped, as for commit. Applies to every maintained branch (syncMetaData() exists since 6.2.0).

What the checks validated

Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, 2026-10-31: the repro passes with assertions on and off (it fails on the unfixed tree); the repro with TestPersistentSnapshotDeletionPolicy and TestSnapshotDeletionPolicy passes 21 of 21 with seed DEADBEEF, assertions on and off, and 105 of 105 with -Ptests.iters=5 (a random master seed) (unfixed, the two suites also pass 20 of 20); :lucene:core:check -x test (format, ecj lint, forbidden APIs) passes. Earlier, the finder's, the independent reproduction's and the refute reviewer's crash-model tests passed with the same line.

Also syncMetaData() after deleting the previous save file (not built)

Makes the delete durable too, so a crash never leaves two save files.

Trade-offs

Not needed for the new state to win (the loader always loads the newest file); needed only if the old file's removal must be durable at once. Costs a second directory fsync.

Document that callers must call syncMetaData() (not recommended)

Change the javadoc instead of the code.

Trade-offs

Weakens a documented guarantee ("persisted once this method returns") and leaves existing users exposed.

Workaround until fixed: call syncMetaData() yourself

Call snapshotDir.syncMetaData() right after snapshot() and after release() (public API).

Trade-offs

Every application has to know about it. Under the crash model this control passes (explicit syncMetaData() after snapshot()).

Downstream note

Solr's SolrSnapshotMetaDataManager, a copy of this class, has the same persist() shape on apache/solr main: it syncs the new file and deletes the old one with no syncMetaData() (read, not run).

How it was found

A bounded-exhaustive crash-state enumeration of the real Lucene jars, under a model in which a new name is durable only after syncMetaData(), with real Lucene code reopening each state: 4 of 1,123 distinct crash states, right after snapshot() or release() returned, reloaded an older snapshot state (4 more belong to a sibling loader bug). JUnit tests then pinned the cause.

The method in full254 words

Method: semi-formal (bounded-exhaustive checking of the real jar).

  • An AI finder built a recording Directory that logs every create, close, sync, syncMetaData, rename and delete, and a crash model: file bytes are durable only after sync, directory operations only after the next syncMetaData() (the rule Lucene's commit code follows, LUCENE-5588 and LUCENE-8048, and the Linux fsync(2) man page). Every prefix of the recorded operations is a crash point; for each, the not-yet-durable directory operations survive as a prefix in program order (an ordered-metadata model), with content variants for unsynced files.
  • The oracle is real Lucene code on each crash image: DirectoryReader.open, CheckIndex, IndexWriter recovery and, for the snapshot workload, the reloaded snapshot state, which must be the last acknowledged one or an in-flight one.
  • In the snapshot workload (272 file-system operations, 8 syncMetaData() calls, 5 commits, 4 snapshot operations) 8 of 1,123 distinct crash states failed: 4 right after a snapshot() or release() returned (this bug; crash points op#68, #190, #195 and #262) and 4 inside persist() before its file sync (a sibling bug, in which the loader adopts a broken newest save file; this fix alone does not remove those). Vacuity mutants of the harness (for example, never syncing .cfs files) are caught, and with the unit's fix sketches (this fix plus the sibling's) the workload has 0 failures.
  • The finder then wrote a public-API JUnit check; independent reproducers wrote their own tests from the claim. The one-test repro was cut from the finder's protocol check on 2026-10-29.

Prior reports, history and checks

Prior reports

No prior report: 91 searches over GitHub issues, PRs and commits, the JIRA archive, four mailing lists, downstream projects and the web, plus the files of all 413 open PRs (2026-10-26), rechecked on GitHub on 2026-10-31. Open PR #16482 modernizes the class but leaves persist() unchanged.

Searches4 searches

Verdict new: no issue, PR, JIRA entry, list post or web page names PersistentSnapshotDeletionPolicy or snapshots_N together with a missing directory fsync or syncMetaData(). Related, not reports of this bug: #6987 (LUCENE-5925, the introducing change; its description and comments never mention snapshots), #6650 (LUCENE-5588), #8427 (LUCENE-7373, added syncMetaData() and the MockDirectoryWrapper TODO), #9096 (LUCENE-8048), #6040 (LUCENE-4976), #3555 (LUCENE-2481), #14334 (a general fsync discussion that mentions a parent-directory fsync in passing; several maintainers there asked for concrete cases before fsync changes). Gaps: GitHub search tokenizes camelCase poorly (variants were added); the lists were searched through the lists.apache.org API only; private lists and private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream repositories2026-10-26the scripted prior-report search (18 issue/PR, 3 commit and 2 downstream queries) and manual searches in apache/lucene, apache/solr, Elasticsearch and OpenSearchonly the related items above
Open PRs2026-10-26the changed files of all 413 open apache/lucene PRsonly #16482 touches the class; it leaves persist() unchanged
Lists, JIRA, web2026-10-26dev@, java-user@, issues@ and commits@ (20 queries); LUCENE and SOLR JIRA (11 issues read in full); 14 web queriesno report
Recheck (GitHub)2026-10-31PersistentSnapshotDeletionPolicy; PersistentSnapshotDeletionPolicy syncMetaData; snapshots_ fsync directory; snapshot syncMetaData; SnapshotDeletionPolicy durable; the upstream file on main, branch_11x and branch_10_6nothing new; persist() unchanged upstream; #16482 still open

History

Since 5.0.0: LUCENE-5925 (2014) moved the directory fsync out of FSDirectory.sync() into the rename that only the segments_N publish calls, and the snapshot policy lost it as a side effect. Executed: 4.8.0-4.10.4 pass, all 107 releases 5.0.0-10.5.2 fail, as do 10.6.0 and main. Not fixed upstream.

Versions tested4 rows

Introduced by LUCENE-5925 "use rename instead of segments_N fallback/segments.gen" (Robert Muir, 2014-09-11; JIRA, now #6987; no pull request then): b7047694256 on branch_4x, first released in 5.0.0, and bb0256f2607 on trunk (6.0.0 and every later release). It removed IOUtils.fsync(directory, true) from FSDirectory.sync() and put it into the new renameFile(). persist()'s file-only sync() dates from LUCENE-4976 (2013, 4.4.0); 4.8.0-4.10.4 were covered by the directory fsync LUCENE-5588 had added to sync(). Before 4.8.0 no Lucene code fsynced any directory (a Lucene-wide limitation, no verdict by execution). LUCENE-7373 (6.2.0) added syncMetaData(); persist() was never updated. Upstream main 2f424fd428da, branch_11x and branch_10_6 still have the same persist() (2026-10-31).

Versions and branches executed, with results
Version or commitVerdictResult
4.8.0 to 4.10.4 (9 releases) not affectedthe fsync probe passes: the stock FSDirectory.sync() also fsynced the directory then
5.0.0 to 10.5.2 (107 releases) first: 5.0.0the fsync probe fails on every one; the finder's JUnit (a protocol check and 2 crash-model tests) fails 3 of 3 on 6.2.0, 8.11.4, 9.12.3 and 10.5.2, and the independent reproduction's crash-model test 3 of 3 on 9.12.3 and 10.5.2
10.6.0 affectedthis doc's test and the fsync probe fail
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with the fix

Independent checks

An independent reproducer, a refute review and a works-as-designed review by another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate 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 verdicts298 words

A first refute attempt returned "not real" for a procedural reason only and was retried. Every reproducer modelled the crash with Lucene's Directory durability rules; nobody tested a real power cut.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateCrash-state enumeration (How it was found); its JUnit fails 3 of 3 on main (assertions on and off), 10.5.2, 9.12.3 and 8.11.4, and passes with the fix.
Refute review (OpenAI GPT-5.6, executed) real (high)Own test fails on main with assertions on and off and 3 seeds (snapshot count 0 instead of 1); its explicit-syncMetaData control passes; the fix makes it pass.
Independent reproducer realOwn crash-model test: 3 of 3 fail on main, 10.5.2 and 9.12.3 (separate Directory, index Directory, release()); 3 controls pass; the fix passes.
Works-as-designed lens (OpenAI GPT-5.6, read-only) real (medium)Could not run code; intended argument weak, root cause agreed; a separate run re-ran the finder's repro (3 of 3 fail).
Devil's advocate realHigh that it breaks its own contract and is an unintended regression, medium-low that it matters on mainstream file systems; found that 4.8.0-4.10.4 (stock FSDirectory) are not affected.
Adjudication confirmed (high)Re-ran the independent reproduction's test with a new seed (3 of 3 fail, assertions on and off), its controls and the fix (pass); corrected the version history and the impact.
Security screen noneOnly a machine-level failure triggers it; the Runtime.halt probe shows process death does not (10 of 10 survived).
This doc's re-run (645d4fe14a20) reproducesThe one-test repro fails through gradle and the standalone runner (assertions on and off; 10.6.0, 10.5.2, 9.12.3); the crash-model test fails 3 of 3 with its controls passing; the fsync probe fails on main and 10.6.0; the fix passes.

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,268 words

Finding record

LFV-194, snapshot of 2026-11-03

Registry title
PersistentSnapshotDeletionPolicy.snapshot() and release() return before the new save file's name is durable: persist() fsyncs snapshots_N but never calls Directory.syncMetaData(), so an OS crash or power loss can lose an acknowledged snapshot
Area
index / deletion policies (PersistentSnapshotDeletionPolicy persistence, directory fsync)
Priority
Low, rank 850: PersistentSnapshotDeletionPolicy never fsyncs the directory after writing a save file, so a crash on a non-journaling file system can lose an acknowledged snapshot, opt-in and contrived.
Impact class
data corruption
Impact note
Hits only applications that use the opt-in, @lucene.experimental PersistentSnapshotDeletionPolicy (typically to keep a commit for a backup or replication) when the OS crashes or the machine loses power shortly after snapshot() or release() returned, on a file system where fsync of a new file does not also persist its directory entry (POSIX and the Linux fsync(2) man page allow this; journaling file systems such as ext4, XFS and btrfs are expected to persist the name, which was not tested): the acknowledged snapshot record is lost and, if the snapshots live in a separate Directory, the reopened IndexWriter deletes the snapshotted commit; the live index stays consistent, no scale is needed, and the death of the JVM process alone does not trigger it (executed with Runtime.halt).
Summary
You protect a commit with PersistentSnapshotDeletionPolicy.snapshot(), whose javadoc says that once the method returns the snapshot information is persisted in the directory. Instead persist() writes and fsyncs the new snapshots_N file but never fsyncs the directory (Directory.syncMetaData()), the step Lucene's own commit takes because a new file's name is not durable until then. After an OS crash or power loss the save file can be gone: the snapshot count comes back as 0 and, with the snapshots in a separate Directory, the reopened IndexWriter deletes the commit the snapshot was protecting (a lost release() instead brings a released snapshot back).
Introduced
b7047694256a (branch_4x, svn r1624362, first released in 5.0.0) and its trunk twin bb0256f26076 (svn r1624194, first released in 6.0.0, an ancestor of every 6.x-10.x release and main): LUCENE-5925 'Use rename instead of segments_N fallback / segments.gen etc' by rmuir (JIRA, migrated as #6987; 2014, before GitHub PRs, so no PR) [read, then confirmed by execution at the release boundary: the change removed IOUtils.fsync(directory, true) from FSDirectory.sync() and put it into the new FSDirectory.renameFile(), which persist() never calls; PersistentSnapshotDeletionPolicy.java is identical in 4.10.4 and 5.0.0; the fsync probe passes on 4.10.4 and fails on 5.0.0]. persist()'s file-only sync itself dates from LUCENE-4976 (8c6e38ee605, 2013, first released in 4.4.0); 4.8.0-4.10.4 were covered by the directory fsync that LUCENE-5588 had put inside FSDirectory.sync(). (2014-09-11 (LUCENE-5925, committed on trunk and branch_4x the same day))
First affected version
5.0.0 [exec] (fsync probe: 4.10.4 passes, 5.0.0 fails; 4.10.4 shipped after 5.0.0 from the 4.10 bugfix branch, which never took LUCENE-5925)
Affected versions
all 107 Maven Central releases 5.0.0-10.5.2, 10.6.0 and main ea57360912ed [exec: the one-test repro and the fsync probe fail on the 10.6.0 jars with -ea (2026-10-31); the fsync probe fails on every one, in both setups (separate snapshot Directory and index Directory); the crash-model JUnit fails 3/3 on 6.2.0, 8.11.4, 9.12.3, 10.5.2 and main with -ea and -da]. Not affected: 4.8.0-4.10.4 (9 releases) [exec: the probe passes, because FSDirectory.sync() also fsynced the directory then, LUCENE-5588]. 4.4.0-4.7.2: no verdict by execution (the recorder cannot see their RandomAccessFile fsyncs); by source no Lucene code fsynced any directory then, a Lucene-wide limitation that LUCENE-5588 removed, not this regression [read]. Upstream main, branch_10x, branch_10_6 and branch_9_12 still have the same persist() (read-only check 2026-10-26). Directory.syncMetaData() exists from 6.2.0 (LUCENE-7373), so the one-line fix applies from 6.2.0 on [read].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: 91 queries, among them a scripted prior-report search over GitHub issues/PRs (18), commits (3), downstream repositories (2), the dev@, java-user@, issues@ and commits@ lists (20), the JIRA archive (4) and the web (6), 297 distinct hits with every high- and medium-overlap hit opened; manual GitHub searches in apache/lucene, apache/solr, Elasticsearch and OpenSearch; JIRA REST for the LUCENE and SOLR projects (11 issues read in full); a file scan of all 413 open apache/lucene PRs; the local git history; earlier passes by the finder and by the intended-behaviour check also found nothing); no item names PersistentSnapshotDeletionPolicy or snapshots_N together with a missing directory fsync, syncMetaData() or file-name durability; not fixed upstream: the only open PR on the file, #16482 (gsmiller; changes the loader and makes getLastSaveFile synchronized, which is LFV-040), leaves persist() unchanged; related context, not reports of this bug: #6987 (LUCENE-5925, the introducing change; its description and 21 comments never mention snapshots or other sync() callers), #6650 (LUCENE-5588), #8427 (LUCENE-7373, which added syncMetaData() and the MockDirectoryWrapper.crash() TODO that hides this bug from the upstream tests), #9096 (LUCENE-8048), #6040 (LUCENE-4976), #14334 (general fsync-after-close discussion that mentions a parent-directory fsync in passing, not PSDP); gaps: GitHub search tokenizes camelCase poorly (variants were added), mailing lists were searched through the lists.apache.org API only, private lists and private security reports cannot be searched
Lucene commit tested
main 645d4fe14a20 (the one-test repro through gradle and the standalone runner, assertions on and off; the crash-model test and its controls; the fsync probe; the one-line fix through gradle with the snapshot-policy suites and :lucene:core:check, 2026-10-31); the 10.6.0, 10.5.2 and 9.12.3 release jars (the same test) and 10.6.0 (the fsync probe). The hunt, the verifiers and the adjudication ran on main ea57360912ed, where every file involved is identical, and a main refresh on a9779ef3c193 reproduced it; the fsync probe ran on all 116 releases from 4.8.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:34-47 (class javadoc: persisted and committed as soon as snapshot() or release() is called; a different Directory per writer, the index Directory is also OK; @lucene.experimental), :101-102 (APPEND with no save file throws), :106-108, :128-130, :149-151 ('Once this method returns, the snapshot information is persisted in the directory'), :161-184 (persist: createOutput, dir.sync at :175, delete of the previous save file at :177-181, no syncMetaData)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java:35-37 (in-memory, 'not protected against system failures. If persistence is important, you can use PersistentSnapshotDeletionPolicy'); lucene/core/src/java/org/apache/lucene/store/Directory.java:104-119 (sync = writes to these files; syncMetaData = directory metadata); lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:244-251 (sync: file contents only), :268-274 (syncMetaData: IOUtils.fsync(directory, true)); lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:970, :1007 (commit calls syncMetaData before and after publishing segments_N)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:35-47 (at 645d4fe14a20) (class javadoc), :113-159 (snapshot, release), :161-184 (persist; dir.sync at :175, delete at :177-181), :211-264 (loadPriorSnapshots)
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:104-119; (at 645d4fe14a20) store/FSDirectory.java:243-251, :268-274; index/SegmentInfos.java:966-972, :605-621, :995-1012; index/IndexWriter.java:5974 (prepareCommit), :5993 (file sync)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:47 (at 645d4fe14a20) (@lucene.experimental), :101-103 (APPEND with no save file)
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:526-533 (at 645d4fe14a20) (no directory fsync on Windows)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:35-37, (at 645d4fe14a20) :106-108, :128-130, :149-151
  • lucene/core/src/java/org/apache/lucene/index/SnapshotDeletionPolicy.java:35-37 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestPersistentSnapshotDeletionPolicy.java:66-67; (at 645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:339-340, :512-513
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:218, (at 645d4fe14a20) :253-260 (loader keeps the newest save file)
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/PersistentSnapshotDeletionPolicy.java:161-184 (at 645d4fe14a20)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs; the PNG is the summary still.

Built 2026-11-03 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.