- 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 filesnapshots_Nso they survive a restart. - A new file reaches disk in two parts: its bytes and its directory entry (the name). Lucene's
DirectoryAPI 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.
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.- You pin a commit with
PersistentSnapshotDeletionPolicy, snapshots in their ownDirectory: commit 1, thensnapshot(), which writessnapshots_0, then commit 2. Both commits stay on disk because commit 1 is pinned. - You expect what the javadoc says: once
snapshot()returns, the snapshot is persisted and survives a crash of the machine, as a commit does. - Instead
persist()fsyncs the bytes ofsnapshots_0but never callsDirectory.syncMetaData(), so the file's name (its directory entry) is not yet on disk. Lucene's own commit callssyncMetaData()before and after publishingsegments_Nfor exactly this reason. - 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 reopenedIndexWriterkeeps only commit 2: commit 1, the one the snapshot protected, is deleted (shown under a simulated crash built on Lucene'sDirectorysplit ofsync()andsyncMetaData(), not after a real power cut).
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.
- Snapshot:
PersistentSnapshotDeletionPolicy(PSDP,@lucene.experimental) wraps another deletion policy and keeps every snapshotted commit untilrelease(). It stores the reference counts per commit generation in a save filesnapshots_Nin aDirectoryof your choice: its own, or the indexDirectory("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). OnFSDirectorythe 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."
snapshot()(:113-126),release(IndexCommit)(:135-147) andrelease(long)(:156-159) update the counts and callpersist().persist()(:161-184) createssnapshots_<nextWriteGen>and writes the counts (:162-173), callsdir.sync(Collections.singletonList(fileName))(:175), deletes the previous save file (:177-181) and returns. Nothing in the class callssyncMetaData(), and nothing ever did (git log -S syncMetaDataon the file: no commits).- Lucene's commit does call it:
SegmentInfos.prepareCommit()before writingpending_segments_N(SegmentInfos.java:970) andfinishCommit()after renaming it tosegments_N(:1005-1007), and by the second call every file of the commit,segments_Nincluded, is already synced (IndexWriter.java:5993,SegmentInfos.java:620): commit does not rely on a file's sync to make its name durable. - So, when
snapshot()returns, the save file's bytes are durable and its name is not. In the indexDirectory, the writer's next commit makes the name durable; a separate snapshotDirectoryis never fsynced by Lucene at all. - On restart
loadPriorSnapshots()loads the newestsnapshots_Nit 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 lostrelease()).
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()(writessnapshots_0), commit 2, crash. After the crash the snapshot directory is[],getSnapshotCount()is 0 instead of 1, and the reopenedIndexWriterkeeps 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_1is lost and the not-yet-durable delete ofsnapshots_0is 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
IndexWriterConfigusesKeepOnlyLastCommitDeletionPolicyand 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()orrelease()returned and before the directory entry reaches disk. With the snapshots in the indexDirectorythat window ends at the writer's next commit; with their ownDirectoryit lasts until the OS writes the directory back. - Process death is not a trigger. A probe called
Runtime.halt()right aftersnapshot()orrelease()returned, with nothing closed, and reopened the index in a new JVM: 10 of 10 acknowledged states survived.kill -9and 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
IndexWriterreopens (separateDirectory) or at the next commit (indexDirectory). A backup or replica still copying that commit loses its source. A lostrelease()keeps a commit longer than asked. The live index stays consistent and searchable. - By reading only (not run): reopening with
OpenMode.APPENDafter losing the only save file throwsIllegalStateException("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).
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).
/*
* 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);
}
}
}
}
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()callssyncMetaData()on the snapshot directory at least once before it returns, so the namesnapshots_0is 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).
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)- The javadoc: "Once this method returns, the snapshot information is persisted in the directory";
SnapshotDeletionPolicypoints 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 thesegments_Nrename.
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.
Directoryseparatessync()(file writes) fromsyncMetaData()(directory metadata), and commit callssyncMetaData()twice (SegmentInfos.java:970,:1007), the second time after every file of the commit,segments_Nincluded, is synced. - The test intent.
TestPersistentSnapshotDeletionPolicychecks// Make sure we fsync: dir.crash();(:66-67), whereMockDirectoryWrapper.crash()"Simulates a crash of OS or machine" (MockDirectoryWrapper.java:512). It cannot see this bug only becausecrash()damages or deletes only files that were neversync()ed (:356-358,:526) and never drops the name of async()ed file, whether or notsyncMetaData()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 stockFSDirectory.sync()also fsynced the directory (LUCENE-5588), so the name was durable. LUCENE-5925 (2014, 5.0.0) moved that fsync into the newrenameFile(), which only thesegments_Npublish calls; its discussion never mentions snapshots.PersistentSnapshotDeletionPolicy.javais 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.
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.
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.
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.
Change the javadoc instead of the code.
Trade-offs
Weakens a documented guarantee ("persisted once this method returns") and leaves existing users exposed.
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()).
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).
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
Directorythat logs every create, close,sync,syncMetaData, rename and delete, and a crash model: file bytes are durable only aftersync, directory operations only after the nextsyncMetaData()(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,IndexWriterrecovery 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 asnapshot()orrelease()returned (this bug; crash points op#68, #190, #195 and #262) and 4 insidepersist()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.cfsfiles) 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream repositories | 2026-10-26 | the scripted prior-report search (18 issue/PR, 3 commit and 2 downstream queries) and manual searches in apache/lucene, apache/solr, Elasticsearch and OpenSearch | only the related items above |
| Open PRs | 2026-10-26 | the changed files of all 413 open apache/lucene PRs | only #16482 touches the class; it leaves persist() unchanged |
| Lists, JIRA, web | 2026-10-26 | dev@, java-user@, issues@ and commits@ (20 queries); LUCENE and SOLR JIRA (11 issues read in full); 14 web queries | no report |
| Recheck (GitHub) | 2026-10-31 | PersistentSnapshotDeletionPolicy; PersistentSnapshotDeletionPolicy syncMetaData; snapshots_ fsync directory; snapshot syncMetaData; SnapshotDeletionPolicy durable; the upstream file on main, branch_11x and branch_10_6 | nothing new; persist() unchanged upstream; #16482 still open |
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).
| Version or commit | Verdict | Result |
|---|---|---|
| 4.8.0 to 4.10.4 (9 releases) | not affected | the fsync probe passes: the stock FSDirectory.sync() also fsynced the directory then |
| 5.0.0 to 10.5.2 (107 releases) | first: 5.0.0 | the 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 | affected | this doc's test and the fsync probe fail |
| main 645d4fe14a20 | affected | this doc's test fails through gradle and the standalone runner, assertions on and off; passes with the fix |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | Crash-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 | real | Own 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 | real | High 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 | none | Only 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) | reproduces | The 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. |
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 592 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,268 words
- 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 mainea57360912ed, where every file involved is identical, and a main refresh ona9779ef3c193reproduced it; the fsync probe ran on all 116 releases from 4.8.0 to 10.5.2.
- 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-151lucene/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)
- TestPersistentSnapshotSyncMetaData.java (one JUnit test):
repro/LFV-194/src/org/apache/lucene/index/TestPersistentSnapshotSyncMetaData.java(54 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs; the PNG is the summary still.
assets/LFV-194/bug.png(592 KB)
