All findings · Lucene finding · LFV-205 · nrtcaching-sync-creates-files-after-dir-barrier

NRTCachingDirectory: an OS crash during commit can leave an index that will not open

NRTCachingDirectory writes a commit's cached segment files to disk only after the directory fsync that makes file names durable. An OS crash or power loss after the segments_N rename, on a file system that persisted the rename first, leaves an index that will not open until someone deletes that segments_N.

Summary

Priority
Low, rank 605NRTCachingDirectory (Solr's default directory factory) creates a commit's cached files after the directory fsync, so an OS crash can leave an index that will not open until the newest segments_N is deleted by hand, but only on file systems that persist a rename before earlier creates, which ext4 and XFS are not expected to do.
Impact
index unusable
Since
7.2.0 as an NRTCachingDirectory-only gap (LUCENE-8048, 2017, fixed this failure for plain directories only)introduced 2017-12-05 · about 8 years
Affected
7.2.0 to 10.6.0 and main (all 71 releases from 7.2.0 to 10.5.2 run, plus 10.6.0)
Status
documented
Found by
crash-state enumeration of the real jar under the LUCENE-8048 crash model, then a JUnit test

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

The finding in 30 seconds

A commit makes file names durable with one directory fsync, then publishes itself by renaming pending_segments_N to segments_N. NRTCachingDirectory writes its cached segment files to disk between the two, so their names are not covered.

Background

  1. Bytes and names. A file fsync makes a file's bytes durable. A directory fsync (syncMetaData) makes its names durable: which files exist, and renames. Since LUCENE-8048, Lucene assumes that names in one directory may reach the disk in any order.
  2. Commit. commit() fsyncs the directory, writes pending_segments_N, fsyncs every file the commit references, then renames pending_segments_N to segments_N. The rename publishes the commit, so a crash should leave the old commit or the new one.
  3. NRTCachingDirectory. It keeps small newly flushed or merged segment files in RAM and writes them to the wrapped directory when the commit calls sync.
Summary card in four panels. 1, what you do: an IndexWriter on new NRTCachingDirectory(FSDirectory.open(path), 5.0, 60.0) adds a document and commits twice, and the OS crashes during commit 2; expected: after the crash the index opens at commit 1 or commit 2. 2, inside commit 2 on a timeline: dir.syncMetaData() makes _0.* and segments_1 durable; pending_segments_2 is created; NRTCachingDirectory.sync creates _1.cfs, _1.cfe and _1.si and fsyncs only their bytes; the rename to segments_2 publishes commit 2; the OS crashes before the next directory fsync. 3, after the crash: on disk _0.cfe, _0.cfs, _0.si, segments_1 and segments_2, lost _1.cfe, _1.cfs and _1.si; DirectoryReader.open throws CorruptSegmentInfoException: segment info file: _1.si cannot be read, and listCommits and new IndexWriter fail the same way, while the plain directory opens at segments_2. 4, the fix in two methods: NRTCachingDirectory.sync calls in.syncMetaData() when unCache, which now returns whether it created a file, created one; with it the repro passes; the alternative site, one more dir.syncMetaData() in SegmentInfos.finishCommit before the rename, passes the repro too. Footer: the crash-model caveat, releases 7.2.0 to 10.5.2, and Lucene main @ 645d4fe14a20.
Summary card: what you do, the order of operations inside commit 2 (directory fsync, the cached _1 files created after it, the rename, the crash), the crash state with its exception, and the fix (NRTCachingDirectory.sync, plus unCache returning whether it created a file). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index through new NRTCachingDirectory(dir, 5.0, 60.0) (the class javadoc's example) and commit twice, one document each. The second commit's new segment _1 is tiny, so its 3 files (_1.cfe, _1.cfs, _1.si) sit in the RAM cache.
  2. Commit 2 first fsyncs the index directory, so the 4 names on disk (_0.cfe, _0.cfs, _0.si, segments_1) are durable. Only then does NRTCachingDirectory.sync copy the 3 _1 files to disk; with an FSDirectory delegate only their bytes are fsynced, and it makes no syncMetaData() call.
  3. Commit 2 then renames pending_segments_2 to segments_2 and fsyncs the directory again. You expect a crash at any moment to leave commit 1 or commit 2.
  4. Instead, if the machine crashes when the rename has reached the disk but the 3 _1 names have not (an order the crash model Lucene adopted in LUCENE-8048 allows), segments_2 points at a missing _1.si: DirectoryReader.open, listCommits and new IndexWriter all throw CorruptSegmentInfoException.
  5. A plain FSDirectory opens fine under the same crash model: its _1 files already exist at the first fsync. Deleting segments_2 by hand recovers commit 1.

Who is affected

Applications that index through NRTCachingDirectory (opt-in, @lucene.experimental; Solr's default configset uses its factory) and suffer an OS crash or power loss inside commit(), on a file system that persists the rename before earlier creates. A commit with one cached segment suffices. Rare: ext4 data=ordered and XFS are not expected to reorder like this, and a JVM crash cannot cause it.

Mechanism, terms and numbers513 words

NRTCachingDirectory keeps small newly flushed segment files in RAM and writes them to the wrapped directory when IndexWriter.commit() calls Directory.sync. Since LUCENE-8048 (7.2), a commit fsyncs the index directory in SegmentInfos.prepareCommit so that every name it references is durable before pending_segments_N is renamed to segments_N, the step that publishes the commit. IndexWriter.startCommit calls directory.sync only after that fsync, so with NRTCachingDirectory the new segment files are created on disk after the barrier, and the next directory fsync comes only after the rename. On a file system that persists that rename before the earlier creates (the model LUCENE-8048 adopted; ext4 data=ordered and XFS are not expected to do it), an OS crash or power loss in that window leaves a newest segments_N whose files are missing: DirectoryReader.open, listCommits and new IndexWriter all throw until someone deletes it by hand, which recovers the previous commit. A plain FSDirectory passes the same crash test: its files exist before the barrier. One deterministic test shows it on main, 10.6.0 and older releases; all 71 releases from 7.2.0 to 10.5.2 fail the hunt's longer tests. One directory fsync in NRTCachingDirectory.sync when it wrote cached files fixes it.

The code (main 645d4fe14a20)

  1. The barrier. SegmentInfos.prepareCommit (SegmentInfos.java:966-972) calls dir.syncMetaData() (:970, added by LUCENE-8048) and then write(dir), which writes and fsyncs pending_segments_N (:620). Every name that exists at :970 is durable from then on.
  2. The order. IndexWriter.startCommit calls toSync.prepareCommit(directory) (IndexWriter.java:5974) and only afterwards filesToSync = toSync.files(false); directory.sync(filesToSync); (:5992-5993).
  3. What is cached. NRTCachingDirectory.createOutput sends a file to the RAM cache when doCacheWrite accepts it (NRTCachingDirectory.java:159-163): a flush or merge whose estimated size is at most maxMergeSizeMB while the cache stays under maxCachedMB (:231-245). So a small new segment is not in the wrapped directory at step 1.
  4. Where the files are created. NRTCachingDirectory.sync (:169-178) calls unCache for each name; unCache creates the file in the wrapped directory with in.copyFrom (:305-323, the copy at :319). Then in.sync(fileNames) (:177) fsyncs the files; with an FSDirectory delegate, their bytes only (FSDirectory.sync, FSDirectory.java:244-251). The class itself never calls syncMetaData.
  5. The publish. SegmentInfos.finishCommit (:996-1012) renames pending_segments_N to segments_N (:1005) and only then fsyncs the directory (:1007).
  6. Result. Between :1005 and the end of :1007, the rename and the creates of step 4 are both pending, with no order between them. If the rename reaches the disk first and the machine crashes, the newest segments_N names files that are not there (in the test: _1.si, _1.cfs, _1.cfe). With a plain directory those files already exist at step 1, so the plain control passes.

Why upstream tests miss it

MockDirectoryWrapper.crash() damages or deletes only files whose bytes were never fsynced and never undoes creates, renames or deletes that lacked a directory fsync: "TODO: we should also mess with any recent file renames, file deletions, if syncMetaData was not called!!" (MockDirectoryWrapper.java:339-340, :526). TestNRTCachingDirectory has no crash test.

Exposure and likelihood270 words
  • Who. Applications that write through NRTCachingDirectory, the near-real-time setup its javadoc shows (new NRTCachingDirectory(fsDir, 5.0, 60.0), NRTCachingDirectory.java:50-58). Opt-in and @lucene.experimental (:63); not the default Directory. Per the prior-report search notes, Solr's _default configset uses NRTCachingDirectoryFactory; whether Solr's commit path reaches this was not checked.
  • Trigger. A machine-level failure (OS crash, kernel panic, power loss) inside commit(), between the segments_N rename and the directory fsync after it, on a file system that persists that rename before the earlier creates. POSIX and Linux fsync(2) do not promise that order; it is the model LUCENE-8048 accepted for plain directories. Mainstream ordered-metadata journaling (ext4 data=ordered, XFS) is not expected to reorder like this, and no physical file system was tested. A JVM crash or kill -9 cannot produce the state: completed creates and renames stay visible to the next process whether or not they were fsynced.
  • Effect. Index unusable. In an independent reproducer's probe on the crash state, DirectoryReader.open, DirectoryReader.listCommits and new IndexWriter(CREATE_OR_APPEND) all throw; deleting the newest segments_N by hand recovers the previous commit (after deleting segments_2 the index opens with maxDoc=10). Whoever does that should back up the index first and make sure no reader or writer has it open. Fail-stop: in every enumerated crash state this bug causes, opening throws; none returned wrong or deleted documents. One commit that contains a cached segment is enough; whether a segment is cached depends on the flush or merge context, its estimated size and the cache's free space (NRTCachingDirectory.java:231-245).
  • How likely. Rare; no field report attributable to it was found.

Minimal repro

One JUnit test (public API plus FilterDirectory's protected constructor and in field), RAM directories only, deterministic. A FilterDirectory under the writer remembers which names existed at the last directory fsync; at the rename to segments_2 it copies just those files plus segments_2 into a second directory, the crash state (all bytes kept, which is generous to Lucene), and opens it.

TestNRTCachingDirectoryCommitCrash.java (one test)70 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.store;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNRTCachingDirectoryCommitCrash extends LuceneTestCase {

  public void testCrashRightAfterSecondCommitRename() throws Exception {
    Directory crashState = new ByteBuffersDirectory();
    Directory delegate =
        new FilterDirectory(new ByteBuffersDirectory()) {
          String[] durableNames = new String[0];

          @Override
          public void syncMetaData() throws IOException {
            super.syncMetaData();
            durableNames = listAll();
          }

          @Override
          public void rename(String source, String dest) throws IOException {
            super.rename(source, dest);
            if (dest.equals("segments_2")) {
              for (String name : durableNames) {
                if (name.equals(source) == false) {
                  crashState.copyFrom(in, name, name, IOContext.DEFAULT);
                }
              }
              crashState.copyFrom(in, dest, dest, IOContext.DEFAULT);
            }
          }
        };
    try (IndexWriter w =
        new IndexWriter(new NRTCachingDirectory(delegate, 5.0, 60.0), new IndexWriterConfig())) {
      w.addDocument(new Document());
      w.commit();
      w.addDocument(new Document());
      w.commit();
    }
    // expected: crash state opens at commit 1 or 2; actual: _1 files were created after the fsync
    try {
      DirectoryReader.open(crashState).close();
    } catch (IOException e) {
      fail(
          "crash state (names durable at the last directory fsync, plus the segments_2 rename)"
              + " should open at commit 1 or 2, but: "
              + e);
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestNRTCachingDirectoryCommitCrash -Ptests.seed=DEADBEEF
Expected
The crash state opens at commit 1 or commit 2.
Observed
DirectoryReader.open throws CorruptSegmentInfoException: segment info file: _1.si cannot be read; the crash state has no _1.* file.

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

More files, environment, full output356 words

The crash state is the one LUCENE-8048's model allows: the rename reached the disk, and no name created since the last directory fsync did. Every file's bytes are kept, which is generous to Lucene. Skipping the rename source while copying is part of the model: with either fix pending_segments_2 is durable before the rename, on stock code it never is.

It is a crash-model test, not a physical crash: no file system was crashed. It covers only the second commit, because a crash during the very first commit may leave a corrupt index by design (LUCENE-4738). The seed only drives the test framework's randomisation; the test also fails with seeds 1234ABCD and C0FFEE01. It passes checkGoogleJavaFormat, ecjLintTest and forbiddenApisTest as is.

Regression test: this same test; it passes with either fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with either fix: passes. Also fails on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; pure Java, RAM directories only)
Command (environment)
cp $REPRO/TestNRTCachingDirectoryCommitCrash.java $LUCENE/lucene/core/src/test/org/apache/lucene/store/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestNRTCachingDirectoryCommitCrash -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)

Observed output

# gradle, scratch worktree of 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
java.lang.AssertionError: crash state (names durable at the last directory fsync, plus the segments_2 rename)
  should open at commit 1 or 2, but: org.apache.lucene.index.CorruptSegmentInfoException: segment info file:
  _1.si cannot be read - it may be missing or corrupt (resource=BufferedChecksumIndexInput((clone of)
  ByteBuffersIndexInput (file=segments_2, buffers=246 bytes, block size: 1, blocks: 1, position: 0)))
    at org.apache.lucene.store.TestNRTCachingDirectoryCommitCrash.testCrashRightAfterSecondCommitRename(TestNRTCachingDirectoryCommitCrash.java:64)
:lucene:core:test (FAILURE): 1 test, 1  failure

# released jars, standalone runner, seed DEADBEEF
10.6.0 (-ea, -da)          ... but: org.apache.lucene.index.CorruptSegmentInfoException: segment info file: _1.si cannot be read ...
10.5.2, 9.12.3, 9.1.0 (-da) ... but: org.apache.lucene.index.CorruptIndexException: Unexpected file read error while reading index. ...

# crash-state files (stock main): [_0.cfe, _0.cfs, _0.si, segments_1, segments_2]
# independent impact probe on that state: DirectoryReader.open, DirectoryReader.listCommits and
#   new IndexWriter(CREATE_OR_APPEND) throw CorruptSegmentInfoException;
#   after deleting segments_2 by hand: opens, maxDoc=10 (commit 1 of that probe's 10-documents-per-commit test)

# with fix A or fix B: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • Its javadoc says "This is safe to use": on commit, "all cached files will be flushed from the cached and sync'd".
  • commit() promises to "survive an OS or machine crash or power loss"; its protocol is built to leave the old or new commit.
  • LUCENE-8048 added the directory fsync for exactly this failure; it runs before NRTCachingDirectory writes its files.
The full argument262 words
  • Contracts. NRTCachingDirectory: "This is safe to use: when your app calls {IndexWriter#commit}, all cached files will be flushed from the cached and sync'd" (NRTCachingDirectory.java:47-48). IndexWriter.commit: commits "and syncs all referenced index files, such that a reader will see the changes and the index updates will survive an OS or machine crash or power loss", and Directory#sync "should not return until the file contents and metadata are on stable storage" (IndexWriter.java:4188-4201). Directory.sync: "Lucene uses this to properly commit changes to the index, to prevent a machine/OS crash from corrupting the index" (Directory.java:104-112).
  • The design. The commit is published by one rename (Directory.java:131: rename "is used by IndexWriter to publish commits"). LUCENE-8048 (#9096, CHANGES 7.2.0 "Bug Fixes") added dir.syncMetaData() to prepareCommit for this failure on plain directories; its reporter's fix was "sync index directory after all segments have been written", and with NRTCachingDirectory the segments are written to disk only inside sync(), after that barrier.
  • History (read, not executed). In 4.8-4.10 FSDirectory.sync itself fsynced the directory after the files (LUCENE-5588), which covered the files NRTCachingDirectory had just created; 5.0 (LUCENE-5925) moved that fsync after the rename for every directory; 7.2 (LUCENE-8048) restored a barrier only before the point where NRTCachingDirectory creates its files. No javadoc, CHANGES or MIGRATE entry, test or discussion exempts NRTCachingDirectory.
  • Tests. No upstream test models lost directory updates (the MockDirectoryWrapper TODO above). Scope: only the second-commit case is claimed, because a crash during the very first commit may leave a corrupt index by design (LUCENE-4738, TestIndexWriterOnJRECrash.java:164-170).
The devil's-advocate check244 words

The strongest case: Directory.sync promises only file bytes ("any writes to these files are moved to stable storage", Directory.java:105) and names are syncMetaData's job; the crash falls inside an in-flight commit, before commit() returns; the class is @lucene.experimental; and a committer on LUCENE-8048 (Robert Muir) said a file system that behaves this way "is choosing to corrupt users data" and agreed to the barrier "just to be safe" (#9096).

Why it fails: IndexWriter.commit's javadoc asks Directory#sync for "file contents and metadata" (not decisive alone: sync and syncMetaData are separate APIs, Directory.java:104-119), and either way the combination breaks commit's documented promise to survive an OS crash or power loss, and the class's "safe to use"; no javadoc says which commit an interrupted commit leaves, but the pending_segments_N + rename protocol and LUCENE-8048 are built so that it leaves the old one or the new one, and here the index opens to neither, which is what LUCENE-8048 set out to prevent; @lucene.experimental marks API stability, not a durability exemption; and committers still made the barrier part of the protocol. Reachability lowers impact; it does not make the gap intended. Verdict: not intended, a defect. Cross-model reviewers (OpenAI GPT-5.6) rated the case for "intended" moderate and returned "real"; an intended-behaviour devil's advocate found that none of its seven points survives and returned "real". No physical file system was crashed, so the real-world reach may be close to zero on ordered-metadata file systems.

Suggested fix

Recommended Fsync the directory in NRTCachingDirectory.sync when it wrote cached files (validated)

In NRTCachingDirectory.sync, call in.syncMetaData() when it wrote cached files to disk, so their names are durable before the rename (+10/-3 lines). The test passes, as do the closest store and commit suites. The alternative, one more directory fsync in SegmentInfos.finishCommit before the rename, also passes; it costs every Directory one more fsync per commit.

boolean created = false;
for (String fileName : fileNames) {
  created |= unCache(fileName);
}
in.sync(fileNames);
if (created) {
  // files created in the delegate here must have durable directory entries before the
  // commit's segments_N rename (the LUCENE-8048 barrier ran before they existed)
  in.syncMetaData();
}
All options and trade-offs2 options ยท 707 words

Both fix sites were built and run on a scratch worktree of 645d4fe14a20. A keeps the extra fsync to the affected class; B also covers any other Directory whose sync creates files. Offer both; maintainers may prefer either. Neither was benchmarked.

Fsync the directory in NRTCachingDirectory.sync when it wrote cached files (validated) (recommended)

unCache returns true when it copied a cached file into the wrapped directory (false when the file was not cached), and sync calls in.syncMetaData() after in.sync(fileNames) when it did. Same change as the hunt's and the independent reproducer's fix.

boolean created = false;
for (String fileName : fileNames) {
  created |= unCache(fileName);
}
in.sync(fileNames);
if (created) {
  // files created in the delegate here must have durable directory entries before the
  // commit's segments_N rename (the LUCENE-8048 barrier ran before they existed)
  in.syncMetaData();
}

Trade-offs

  • One extra directory fsync per commit, only when the commit had cached files to write, in a class meant to cut small-file I/O. It runs inside startCommit, the first phase of the two-phase commit, which the prepareCommit javadoc says "syncs the index files" (IndexWriter.java:3721-3725).
  • Fixes only NRTCachingDirectory; another wrapper whose sync creates files would need the same.
  • Variant: call in.syncMetaData() unconditionally (simpler, but a directory fsync on every sync call, including SegmentInfos.write's sync of pending_segments_N, SegmentInfos.java:620: two per commit).

What the checks validated

  • The v2 re-run (own scratch worktree of 645d4fe14a20, removed afterwards; gradle, JDK 25, seed DEADBEEF): the minimal test fails on the stock worktree (1 test, 1 failure, the documented CorruptSegmentInfoException on _1.si); with the fix, the minimal test, TestNRTCachingDirectory, TestIndexWriterCommit, TestCrash, TestCrashCausesCorruptIndex and TestSegmentInfos pass (6 suites, 89 tests, 4 skipped, 0 failures); assertions off, the minimal test and TestNRTCachingDirectory pass (61 tests, 2 skipped, 0 failures). The v2 fact check repeated that last pair with assertions on in its own worktree (61 tests, 2 skipped, 0 failures).
  • Earlier the same day, in another scratch worktree: :lucene:core:test for org.apache.lucene.store.*, TestIndexWriterCommit, TestCrash, TestCrashCausesCorruptIndex, TestSegmentInfos and TestIndexFileDeleter, assertions on: 34 suites, 816 tests, 19 skipped, 0 failures (repeated by the independent fact check); assertions off: the minimal test and TestNRTCachingDirectory (61 tests) pass; checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass.
  • Earlier still: the hunt's, the independent reproducer's and the refute reviewer's tests pass with it, and in the bounded crash enumeration the 31 reader-open failures of the nrtcaching workload disappear.
  • Not run: the full Lucene suite, benchmarks, concurrent use (validated single-threaded only). A thread whose unCache finds the file already moved ("Another thread beat us", NRTCachingDirectory.java:313) returns false, skips the directory fsync and may return before the other thread's fsync. IndexWriter's own commits do not race this way (startCommit, IndexWriter.java:3915, runs inside synchronized (commitLock), :3775); the unconditional variant avoids the question.

One more directory fsync in SegmentInfos.finishCommit, before the rename (validated)

Call dir.syncMetaData() just before dir.rename(src, dest) in finishCommit (+4 lines with the comment). If it throws, the existing catch rolls the commit back (SegmentInfos.java:1014-1017).

// Directory#sync may create files (NRTCachingDirectory writes its cached files to the
// delegate there), after the directory sync in prepareCommit: make every name this commit
// references durable before the rename publishes it
dir.syncMetaData();
dir.rename(src, dest);

Trade-offs

  • Covers any Directory whose sync creates files, not only NRTCachingDirectory.
  • One extra directory fsync per commit for every Directory, including plain FSDirectory, in the second phase of the two-phase commit.
  • Variant (not run): move the prepareCommit barrier here instead of adding one; that keeps today's fsync count but takes that fsync out of the first phase.

What the checks validated

  • Earlier on 2026-10-30, scratch worktree of 645d4fe14a20 (fix A reverted), gradle, JDK 25, seed DEADBEEF: the minimal test passes, assertions on and off.
  • The same 34 store and commit suites as for fix A, assertions on: 816 tests, 19 skipped, 0 failures, including TestNRTCachingDirectory (60 tests, 2 skipped); checkGoogleJavaFormat passes.
  • Assertions off: the minimal test and TestNRTCachingDirectory pass (61 tests); ecjLintMain and forbiddenApisMain pass. The independent fact check repeated the minimal test and TestNRTCachingDirectory with assertions on and off.
  • Not run: the full Lucene suite, benchmarks, the hunt's crash enumeration.

Downstream note

Solr's _default configset uses NRTCachingDirectoryFactory; whether Solr's own commit path reaches this gap was not checked.

How it was found

A crash-state enumerator replayed real IndexWriter commits over a recording Directory and rebuilt the crash states that LUCENE-8048's out-of-order directory-update model allows (a bounded cover). Through NRTCachingDirectory, 31 of 6,340 states could not be opened (75 more belong to a separate issue), none under the ordered model; a TLA+ model and JUnit tests confirmed it.

The method in full199 words

Method: bounded crash-state enumeration of the real jar (the hunt classes it as semi-formal: the first signal came from the executable enumerator, not from a formal tool).

  • Enumerator (2026-10-26). A recording Directory under IndexWriter logs every create, rename, delete, file fsync and directory fsync. For every crash point it builds each crash state allowed by a bounded cover of the directory operations pending since the last directory fsync, and checks that the index opens to an allowed commit with the right content, that CheckIndex is clean and that IndexWriter recovers. The nrtcaching workload was added after reading NRTCachingDirectory.sync while modelling which Directory wrappers create files late. Under the unordered model 106 of 6,340 states failed (0 of 2,350 under the ordered model); this bug's fix alone removes the 31 reader-open failures, and the remaining 75 belong to a separate IndexFileDeleter durability issue, tracked on its own.
  • TLA+ model. CommitDurability.tla (TLC): ReaderOK is violated for NRTCachingDirectory under unordered persistence and holds under ordered persistence and with the fix.
  • Executed tests. The finder's JUnit, an independent reproducer's JUnit and a cross-model reviewer's JUnit fail on stock main and pass with the fix; the minimal repro above is the smallest of them.

Prior reports, history and checks

Prior reports

None found: no issue, PR or list thread reports files created inside sync() after the commit's directory fsync. LUCENE-8048 (#9096) is the parent: the same failure for plain directories, fixed in 7.2; its thread never mentions NRTCachingDirectory. Searched 2026-10-26, refreshed 2026-10-30.

Searches4 searches
  • Parent issue: LUCENE-8048 / #9096 (closed, fixed in 7.2), the same failure mode and crash model for plain directories. Its 24 comments never mention NRTCachingDirectory or wrappers that create files inside sync() (one names the test framework's MockDirectoryWrapper in passing); the reporter's "I'm fairly certain that I didn't find all the places this may be potentially happening" names no site. A maintainer may call this an incomplete fix of LUCENE-8048; that does not make it previously reported.
  • Symptom only: open #7820 / LUCENE-6762 and its duplicate #7009 (a segments_N that references a missing .si, on Windows with plain MMapDirectory, cause undiagnosed; about CheckIndex recovery), PRs #12872 and #16474 (CheckIndex diagnostics), merged #16476 (names the unreadable .si), #10906 (disk full).
  • Other NRTCachingDirectory defects, different: #8243 (LUCENE-7188), #9197 (LUCENE-8149), #5309 (LUCENE-4238).
  • Limits: keyword search; GitHub full-text search can miss identifiers inside code blocks; one query hit the shared rate limit (covered through the JIRA archive). Tracking issue #16937 does not list this bug.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2616 tool queries plus hand searches, incl. open PRs naming NRTCachingDirectory, syncMetaData, finishCommit, startCommit or SegmentInfos; full reads of #9096 and 9 related threadsno report; #9096 is the parent issue
Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive and JIRA REST (LUCENE, SOLR)2026-10-2620 list and 4 JIRA-archive queriesno report
Solr, Elasticsearch, OpenSearch, Lucene.NET; web2026-10-262 downstream code queries, hand searches, 6 web queriesno report of this mechanism
GitHub refresh2026-10-30read-only GitHub CLI queries: issues and PRs updated since 2026-10-25 for NRTCachingDirectory, syncMetaData, finishCommit, pending_segments, "directory fsync commit"; open PRs naming NRTCachingDirectory; upstream main file historynothing new; not fixed

History

An NRTCachingDirectory-only gap since 7.2.0: LUCENE-8048 (2017) fixed this failure for plain directories with a directory fsync that runs before NRTCachingDirectory.sync creates its cached files, which it has done since 2011. All 71 releases from 7.2.0 to 10.5.2 fail, as do 10.6.0 and main; 6.2.0 to 7.1.0 fail for every Directory (LUCENE-8048 itself).

Versions tested6 rows

Origin: an NRTCachingDirectory-only gap since 7.2.0; the ordering behind it is older. LUCENE-8048 "Filesystems do not guarantee order of directories updates" (#9096; JIRA patch, no PR) added dir.syncMetaData() at the start of prepareCommit: 2bfe7ed0f2f9 on branch_7x (first shipped in 7.2.0; release tag dated 2017-12-19) and 4a5900728db6 on master (merged by 0688be6c6677), both 2017-12-05. That barrier runs before directory.sync in startCommit (both lines from b83dde502bc8, 2012), so it fixed plain directories but not NRTCachingDirectory, whose sync has created the cached files since the class was added (LUCENE-3092: 3525ba940fa2 on trunk, 8035a5ecf0f on branch_3x, 2011-05-17; first released in 3.2) and has never called syncMetaData. So 7.2.0 did not introduce the failure: it is the part of LUCENE-8048's failure that its fix did not reach.

Earlier gap for every directory: 4.8-4.10 fsynced the directory inside FSDirectory.sync (LUCENE-5588), which covered the files NRTCachingDirectory had just created; 5.0 (LUCENE-5925) moved that fsync after the rename, so from 5.0 the window was open for every directory; 7.2 restored a barrier only before the point where NRTCachingDirectory creates its files (read from git, not executed).

Not fixed on upstream main 716d6f216f52, branch_11x 603af8703753, branch_10_6 7e5aec4370e0 or branch_9_12 (read-only checks 2026-10-26 and 2026-10-30; branch_10x was retired on 2026-10-29, #16911).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails through gradle (assertions on and off; the assertions-on run repeated in the v2 re-run, both again in the v2 fact check) and on the standalone runner with 3 seeds; passes with fix A and with fix B
10.6.0 (released 2026-10-29) affectedminimal test fails, -ea and -da (CorruptSegmentInfoException on _1.si)
10.5.2, 9.12.3, 9.1.0 affectedminimal test fails (CorruptIndexException, cause the missing _1.si)
7.2.0 to 10.5.2 (all 71 releases) affectedthe hunt's and an independent reproducer's longer tests fail on the NRTCachingDirectory second commit; plain-directory and never-caching controls pass
6.2.0 to 7.1.0 (18 releases) LUCENE-8048 itselffail too, but so does the plain-directory control: no directory fsync before the rename at all
6.1.0 and older no verdictthe Directory.rename / syncMetaData hooks the test needs came in 6.2.0 (LUCENE-7373)

Independent checks

An independent reproducer, a devil's-advocate verifier, an adjudication and a cross-model reviewer (OpenAI GPT-5.6) each ran their own test or re-ran one on stock main and confirmed it; plain-directory controls passed, and the fix passed in every check that ran it. A second GPT-5.6 review found no documented exemption.

Checks and verdicts590 words

Each executed check ran its own test or re-ran an earlier one on stock main (ea57360912ed for the finder, the reviews, the independent reproducer, the works-as-designed and intended-behaviour checks and the adjudication; NRTCachingDirectory, SegmentInfos, IndexWriter, Directory, FilterDirectory and FSDirectory are unchanged from there to 645d4fe14a20; this doc's runs used 645d4fe14a20); the controls (a plain directory, a never-caching NRTCachingDirectory, the fix) pass.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (crash-state enumeration) candidatenrtcaching workload: 106 of 6,340 unordered-model states fail (a bounded cover; 31 of them this bug's reader-open failures, at the segments_1, segments_2 and segments_3 renames, the first-commit ones not claimed here; the other 75 a separate IndexFileDeleter issue), 0 under the ordered model; the JUnit fails 2 of 3 on main, 10.5.2, 9.12.3 and 8.11.4 and passes with the fix.
TLA+ model (TLC) proof-onlyReaderOK violated for NRTCachingDirectory under unordered persistence; holds under ordered persistence and with the fix.
Cross-model review, refute lens (OpenAI GPT-5.6) real (medium)Own 2-test JUnit fails 1 of 2 with -ea, -da and a second seed; plain control passes; with the fix its test and TestNRTCachingDirectory pass. Its test crashes the first commit, which this doc does not claim: it confirms the mechanism, not the second-commit case.
Independent reproducer realOwn JUnit fails 2 of 4 on main, 10.5.2, 9.12.3 and 8.11.4 (40 of 80 over 20 iterations); both controls pass; fix 4 of 4; the impact probe shows open, listCommits and IndexWriter all throw.
Works-as-designed lens (OpenAI GPT-5.6, reading only) real (medium)No documented exemption; its scope correction (claim the second commit only) was accepted.
Intended-behaviour devil's advocate realA seven-point case for intended does not survive; re-ran: 2 of 3 fail on main and 10.5.2.
Adjudication confirmed (high on mechanism)Re-ran the independent reproduction's suite (2 of 4 fail, -ea and -da), the finder's (2 of 3) and the independent reproduction's fix (0 of 4).
Minimal repro verified (2026-10-29)One test fails on main with -ea and -da and 3 seeds; passes with the fix.
Brief v1 runs and fact checks still fails (2026-10-30)Gradle on scratch worktrees of 645d4fe14a20: stock fails; with fix A or fix B the test and 816 store and commit tests pass; the minimal test fails on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 jars. Fact checks (one cross-model) found wording and provenance errors, corrected in v2 except the "public API" label and a pre-7.2 parenthetical, which the v2 fact checks caught.
Brief v2 re-run still fails; fix A passes (2026-10-30)Own scratch worktree of 645d4fe14a20, gradle, seed DEADBEEF: stock 1 test, 1 failure, the documented CorruptSegmentInfoException on _1.si; with fix A the minimal test, TestNRTCachingDirectory, TestIndexWriterCommit, TestCrash, TestCrashCausesCorruptIndex and TestSegmentInfos pass (6 suites, 89 tests, 4 skipped, 0 failures); assertions off the minimal test and TestNRTCachingDirectory pass (61 tests, 2 skipped, 0 failures).
Brief v2 fact checks still fails; both fixes pass (2026-10-30)Independent fact check on a fresh scratch worktree of 645d4fe14a20: the doc's exact gradle command fails with assertions on and off, output as documented; with fix A the minimal test and TestNRTCachingDirectory pass (61 tests, 2 skipped, 0 failures), with fix B the minimal test passes; its own probe over a real FSDirectory delegate confirms each step and both controls. A cross-model fact check (OpenAI GPT-5.6, reading only) found the doc mostly sound. Their wording errors and overclaims are corrected here.

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

Finding record

LFV-205, snapshot of 2026-11-06

Registry title
NRTCachingDirectory.sync creates a commit's cached segment files in the underlying directory after the commit's directory fsync, so an OS crash right after the segments_N rename can leave a newest commit whose files are missing and an index that will not open
Area
store / NRTCachingDirectory (commit durability: IndexWriter.startCommit, SegmentInfos.prepareCommit/finishCommit)
Priority
Low, rank 605: NRTCachingDirectory (Solr's default directory factory) creates a commit's cached files after the directory fsync, so an OS crash can leave an index that will not open until the newest segments_N is deleted by hand, but only on file systems that persist a rename before earlier creates, which ext4 and XFS are not expected to do.
Impact class
index unusable
Impact note
Applications that write through NRTCachingDirectory (opt-in and @lucene.experimental, but the setup its class javadoc recommends for near-real-time search; Solr's _default configset uses NRTCachingDirectoryFactory, and whether Solr's commit path is affected was not checked) whose machine has an OS crash, kernel panic or power loss inside IndexWriter.commit(), in the window between the segments_N rename and the directory fsync that follows it, on a file system that persists that rename before earlier creates in the same directory (allowed by POSIX and by the model Lucene adopted in LUCENE-8048; ext4 data=ordered and XFS are not expected to reorder like this; no physical file system was tested), are left, at any index size and in every release since 7.2.0, with an index that DirectoryReader.open, DirectoryReader.listCommits and new IndexWriter all refuse (CorruptSegmentInfoException on the newest segments_N) until someone deletes that segments_N by hand, which recovers the previous commit; only the in-flight commit is lost.
Summary
You index through NRTCachingDirectory (for example new NRTCachingDirectory(FSDirectory.open(path), 5.0, 60.0), the class javadoc's own example), call IndexWriter.commit(), and the machine crashes during that commit. You expect what Lucene's commit protocol and LUCENE-8048 are built for: after the crash the index opens to the previous commit or to the new one, because SegmentInfos.prepareCommit fsyncs the directory before the commit is published by renaming pending_segments_N to segments_N. Instead the newly flushed segment files are still in NRTCachingDirectory's RAM cache at that directory fsync; NRTCachingDirectory.sync creates them on disk only afterwards and fsyncs their bytes but never the directory, so the rename can become durable before the new files' names do, and in that crash state every open fails (CorruptSegmentInfoException: segment info file _1.si cannot be read), while the same steps on a plain FSDirectory open fine.
Introduced
4a5900728db6a84b4aea42545567a393989f25cf (master, merged by 0688be6c6677) and 2bfe7ed0f2f9 (branch_7x, the copy that shipped first, in 7.2.0): LUCENE-8048 'Filesystems do not guarantee order of directories updates' (committed by Erick Erickson from the JIRA patch; no PR; migrated as #9096) [read: git blame of SegmentInfos.java:970 and git log -S in the full-history clone] [exec: the 7.1.0 / 7.2.0 boundary]. An incomplete fix, not a regression: it added the dir.syncMetaData() barrier at the start of SegmentInfos.prepareCommit, which IndexWriter.startCommit calls before directory.sync(filesToSync) (both lines b83dde502bc8, 2012), while NRTCachingDirectory.sync, which creates the cached files in the delegate inside that sync call (unCache -> copyFrom), has had the same logic since the class was added (LUCENE-3092, 3525ba940fa2 on trunk and 8035a5ecf0f on branch_3x, 2011-05-17) and has never called syncMetaData (git log --all -S syncMetaData on NRTCachingDirectory.java: no commit) (2017-12-05)
First affected version
7.2.0 [exec] (release tag 2017-12-19; CHANGES 7.2.0 'Bug Fixes' lists LUCENE-8048). The 18 releases 6.2.0 to 7.1.0 [exec] fail the same crash test, but so does the plain-FSDirectory control, because no directory fsync preceded the rename at all (LUCENE-8048 itself), so they are not counted as this bug; 6.1.0 and earlier: no verdict (the crash-model hooks Directory.rename and Directory.syncMetaData arrived in 6.2.0 with LUCENE-7373; 6.1.0 does not compile)
Affected versions
every release from 7.2.0 through 10.5.2, 10.6.0 (run later in the bug doc's checks, see below) and main ea57360912ed. [exec] all 71 Maven Central releases from 7.2.0 to 10.5.2 with two independently written JUnit crash-model repros (the finder's, seed DEADBEEF, and the independent reproduction's, seed C0FFEE01): in each, the NRTCachingDirectory second-commit crash state cannot be opened while the plain-FSDirectory control and a never-caching NRTCachingDirectory(0.0, 0.0) control open with all documents; main fails under -ea and -da, and with the fix shadowed main passes. The wrapping exception differs by version (CorruptIndexException 'Unexpected file read error while reading index' on 7.2.0-10.5.2, CorruptSegmentInfoException on main); the cause is always NoSuchFileException for the new segment's .si. [read] not fixed upstream: NRTCachingDirectory.sync still has no syncMetaData on main, branch_10x, branch_10_6 and branch_9_12 (read-only check 2026-10-26); 10.6.0, released from branch_10_6 on 2026-10-29, is affected too: the bug doc's minimal test fails 1 of 1 on the 10.6.0 release jars with -ea and -da [exec, 2026-10-30]. History [read, not executed]: in 4.8-4.10 FSDirectory.sync fsynced the directory after the files (LUCENE-5588), which also covered the files NRTCachingDirectory had just created; 5.0 (LUCENE-5925) moved that directory fsync after the rename, opening the gap for every Directory, and 7.2 (LUCENE-8048) closed it only for files that already exist at prepareCommit. Before 5.0 the commit protocol had no rename and is not analysed. All runs on aarch64 with JDK 25.0.3.
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; also reached by TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only, plus the finder's and the independent reproduction's and the intended-behaviour check's searches: a scripted prior-report search with 16 GitHub issue/PR queries on apache/lucene, 3 commit queries, 2 downstream-repo queries over Elasticsearch, OpenSearch and Solr, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries and 6 web queries; manual GitHub searches on apache/lucene (open PRs naming NRTCachingDirectory, syncMetaData, finishCommit, startCommit or SegmentInfos), apache/solr, OpenSearch, Elasticsearch and Lucene.NET; JIRA REST on LUCENE and SOLR; Solr lists; full reads of LUCENE-8048 (#9096, 24 comments) and of #7820, #7009, #8243, #8649, #8713, #6054, #10906, #10928 and #14334). Parent issue: LUCENE-8048 / #9096 (closed, fixed in 7.2), the same failure mode and crash model for plain directories; its thread never mentions NRTCachingDirectory. Symptom-only overlap: open #7820 / LUCENE-6762 and its closed duplicate #7009 (a segments_N referencing a missing .si, on Windows with plain MMapDirectory, cause undiagnosed; both about CheckIndex recovery), open PRs #12872 and #16474 (CheckIndex diagnostics and exorcise), #10906 (disk full). No open PR touches NRTCachingDirectory.sync, syncMetaData or finishCommit; NRTCachingDirectory.java is unchanged upstream since #15681 (2026-02).
Lucene commit tested
main 645d4fe14a20 (2026-10-28; upstream main 716d6f216f52 has no change since to NRTCachingDirectory.java, SegmentInfos.java or IndexWriter.java): the minimal test fails through gradle in scratch worktrees with assertions on and off (the assertions-on run repeated in the v2 re-run; both again in the v2 fact check), and passes with fix A and with fix B, as do 816 tests in 34 store and commit suites with each fix and, in the v2 re-run with fix A, 89 tests in the minimal test and the 5 closest store and commit suites (0 failures; 61 with assertions off). The minimal test also fails on the released 10.6.0 (assertions on and off), 10.5.2, 9.12.3 and 9.1.0 jars, and on main with seeds DEADBEEF, 1234ABCD and C0FFEE01. Earlier hunt runs: main ea57360912ed and a9779ef3c193 and all 71 Maven Central releases from 7.2.0 to 10.5.2 with the hunt's and an independent reproducer's longer tests (with plain-directory and never-caching controls).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/store/NRTCachingDirectory.java:47-48 (class javadoc: 'This is safe to use: when your app calls {IndexWriter#commit}, all cached files will be flushed from the cached and sync'd.'), :50-58 (example: new NRTCachingDirectory(fsDir, 5.0, 60.0)), :63 (@lucene.experimental), :169-178 (sync: unCache each file, then in.sync; no syncMetaData), :181-186 (rename also unCaches), :305-323 (unCache: in.copyFrom creates the file in the delegate at :319)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:966-972 (prepareCommit: dir.syncMetaData() then write(dir), i.e. pending_segments_N), :996-1012 (finishCommit: dir.rename(src, dest), then dir.syncMetaData())
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4188-4201 (commit javadoc: syncs all referenced index files so that 'the index updates will survive an OS or machine crash or power loss'; Directory#sync 'should not return until the file contents and metadata are on stable storage'), :5974 (startCommit: toSync.prepareCommit(directory)), :5991-5993 (filesToSync = toSync.files(false); directory.sync(filesToSync))
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:105-112 (sync javadoc: writes 'moved to stable storage'; 'Lucene uses this to properly commit changes to the index, to prevent a machine/OS crash from corrupting the index')
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:339-340 (TODO: 'we should also mess with any recent file renames, file deletions, if syncMetaData was not called!!', why upstream crash tests miss this)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterOnJRECrash.java:164-170 (LUCENE-4738: a crash during the first commit may leave a corrupt index by design; CheckIndex only when the last generation is > 1)
  • SECURITY.md (application owns untrusted input; index files and configuration trusted; robustness and durability fixes go to public issues)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:620, (at 645d4fe14a20) :966-972, :996-1012
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5974, (at 645d4fe14a20) :5992-5993
  • lucene/core/src/java/org/apache/lucene/store/NRTCachingDirectory.java:159-163, (at 645d4fe14a20) :169-178, :231-245, :305-323
  • lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:244-250 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:104-119, (at 645d4fe14a20) :131
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:339-340, (at 645d4fe14a20) :526
Who is affected
  • lucene/core/src/java/org/apache/lucene/store/NRTCachingDirectory.java:50-58, (at 645d4fe14a20) :63
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/store/NRTCachingDirectory.java:47-48, (at 645d4fe14a20) :63
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4188-4201 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:104-112, (at 645d4fe14a20) :131
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:339-340 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterOnJRECrash.java:164-170 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3721-3725 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:996-1017 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/NRTCachingDirectory.java:305-323 (at 645d4fe14a20) (unCache; :313 'Another thread beat us'); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3775-3935 (synchronized (commitLock) around startCommit at :3915)

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

Repro files

About the diagrams

The crash state it shows is the modelled one (LUCENE-8048's out-of-order directory updates), not a physical crash.

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.