All findings · Lucene finding · LFV-191 · indexsplitter-copied-files-not-fsynced

IndexSplitter.split commits the destination index without fsyncing the segment files it copied

IndexSplitter.split copies a segment's files into a new index and commits it without fsyncing the copies: only segments_1 and the directory are forced to disk. An OS crash or power loss soon after can leave a destination whose commit names empty files, so it no longer opens.

Summary

Priority
Low, rank 910IndexSplitter never fsyncs the copied segment files, so an OS crash soon after a split can leave an unopenable destination, but it is a rarely used tool and the source stays intact.
Impact
index unusable
Since
3.0.0 (LUCENE-1959, 2009)introduced 2009-10-08 · over 17 years
Affected
3.0.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
code reading while building a file-system crash model of commits (non-formal)

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

The finding in 30 seconds

A Lucene commit is a small file, segments_N, that names every file of every segment. It is crash-safe only if those files are on disk before segments_N is. IndexSplitter.split writes a real commit in the destination but skips that step for the files it copied.

Background: how a Lucene commit survives a crash

  1. Writing a file puts its bytes in the operating system's page cache; the kernel writes them to disk later. fsync (in Lucene, Directory.sync) forces them to disk now, and a directory fsync makes new file names durable.
  2. IndexWriter.commit writes pending_segments_N, fsyncs every file the commit names, renames it to segments_N and fsyncs the directory. Only then is the commit durable.
  3. After a crash, a reader opens the newest segments_N on disk and every file it names. If one of them lost its bytes, the open fails.
Summary card. You do: an index with one segment _0 of one empty document is split into dest/ with IndexSplitter.split, then an OS crash or power loss, then DirectoryReader.open on dest/. Expected: it opens with maxDoc 1. Actual, in the probe's post-crash image: CorruptSegmentInfoException on _0.si, because _0.cfe, _0.cfs and _0.si were never fsynced. A flow of six boxes inside Lucene: Files.copy three times (bytes in RAM); syncMetaData (names durable); pending_segments_1 written and fsynced; rename and syncMetaData (segments_1 durable); crash (the copies' bytes are lost); DirectoryReader.open fails on _0.si. Not affected: IndexWriter commits, the source index (untouched), a process halt (Runtime.halt: 5 of 5 intact). Fix: destFSDir.sync(copied) before destInfos.commit(destFSDir); workaround: run sync.
The minimal repro step by step: split() copies three files, the commit fsyncs only the directory and segments_1, and in a simulated crash the copies are lost and the destination no longer opens. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Your index has one segment, _0, with one document, stored as 3 files (_0.si, _0.cfs, _0.cfe). You copy it into a new index: new IndexSplitter(src).split(dest, new String[] {"_0"}), or IndexSplitter <src> <dest> _0 on the command line.
  2. You expect what every Lucene commit guarantees: before the new segments_1 is durable, every file it names has been fsynced, so the new index survives an OS crash or power loss. IndexWriter.commit works this way.
  3. Instead split() copies the 3 files with Files.copy and calls SegmentInfos.commit, which fsyncs only pending_segments_1 (renamed to segments_1) and the directory. 3 of the 4 files the commit names were never fsynced; their bytes may still be only in the OS page cache.
  4. If the machine crashes or loses power before the kernel writes them back (on Linux, dirty data is written back after about 30 s by default), segments_1 can survive while the copies come back empty. In Lucene's crash model, DirectoryReader.open(dest) then fails with CorruptSegmentInfoException on _0.si.
  5. The source index is untouched, so the split can usually be re-run. Data is lost only if the segment was already removed from the source with IndexSplitter <src> -d _0 and its files deleted. Ending the process does not trigger it: Runtime.halt right after a split left 5 of 5 destinations intact.

Who is affected

Only users of the experimental IndexSplitter tool in lucene/misc (opt-in, rarely used); IndexWriter commits are not affected. It needs an OS crash, kernel panic or power loss soon after a split; the missing sync itself does not depend on index size. The split destination is lost, not the source, unless the segment was already removed from the source.

Mechanism, terms and numbers424 words

IndexSplitter.split (lucene/misc, also the command line IndexSplitter <srcDir> <destDir> <segments>+) copies every file of the chosen segments into the destination with Files.copy and then publishes a commit there with SegmentInfos.commit. That commit fsyncs only the new segments_N and the directory; nothing fsyncs the copied files. So the destination's commit can be on stable storage while the segment files it names are only in the OS page cache, which breaks the rule every other Lucene commit path follows (IndexWriter.commit and the NRT replicator sync the referenced files first). After an OS crash or power loss before writeback, the durable commit can name empty or damaged files, and then the destination does not open: in Lucene's own crash model DirectoryReader.open fails with CorruptSegmentInfoException. The source index is untouched. Present since the tool was added in 3.0.0 (2009), through 10.6.0 and main; the fix is to sync the copied files before the commit.

What split() does (main 645d4fe14a20)

  1. It opens the destination as an FSDirectory and builds a new SegmentInfos (IndexSplitter.java:109-140).
  2. For each chosen segment it copies every file of SegmentCommitInfo.files() with Files.copy(srcFile, destFile) (:141-147, the copy at :146). Files.copy never fsyncs.
  3. It calls destInfos.changed() and destInfos.commit(destFSDir) (:149-150). There is no sync call anywhere in the class.

What SegmentInfos.commit syncs

  • commit(dir) is prepareCommit(dir) then finishCommit(dir) (SegmentInfos.java:1031-1034; javadoc "Writes and syncs to the Directory dir").
  • prepareCommit: dir.syncMetaData() (:970), then write(dir), which creates pending_segments_N and fsyncs only that file (:620).
  • finishCommit: renames it to segments_N (:1005) and fsyncs the directory again (:1007).

What IndexWriter does instead

IndexWriter.startCommit calls prepareCommit (IndexWriter.java:5974), then directory.sync(toSync.files(false)) (:5992-5993), and only later finishCommit. So segments_N becomes durable only after every file it names is. ReplicaNode does the same ("fsync all index files we are now referencing", ReplicaNode.java:364-373). split() skips that middle step.

The recorded order in the minimal repro

copy _0.cfe, copy _0.si, copy _0.cfs; fsync the directory; write and fsync pending_segments_1; rename to segments_1; fsync the directory. The commit names _0.cfe, _0.cfs, _0.si and segments_1; only segments_1 was fsynced. In a post-crash image with the 3 copies left empty, zero-filled or deleted (three of the six damage modes of MockDirectoryWrapper.crash; the probe builds these images itself), DirectoryReader.open fails with CorruptSegmentInfoException: segment info file: _0.si cannot be read - it may be missing or corrupt. Before any crash the destination opens with maxDoc 1.

Exposure and likelihood340 words
  • Who. Operators who run the @lucene.experimental IndexSplitter (IndexSplitter.java:44) from code or the command line. Normal indexing is not affected: IndexWriter.commit and close() fsync every referenced file in every version run, and IndexWriter.addIndexes(Directory) plus commit leaves no referenced file unsynced.
  • Trigger. An OS crash, kernel panic, power loss or hard reset of the machine or VM after split() returns and before the kernel writes the copies back. On Linux, dirty data is written back once it is about 30 s old by default (vm.dirty_expire_centisecs = 3000 on the test host). Ending the process is not enough: after Runtime.halt(0) right after split(), 5 of 5 destinations opened intact in a fresh JVM, since the copies stay in the OS page cache; a kill -9 or JVM crash ends the process the same way (not run). How likely a real crash is to catch copies not yet written back depends on the file system, memory pressure and timing.
  • Outcome. The destination's newest (and only) commit names lost files, so it does not open. The tested crash states fail with an exception at open; no silent wrong results were seen.
  • Severity. Usually recoverable: split() never modifies the source, so the split can be re-run. Data is lost only when the source no longer has the segment's files either, for example in the follow-up the tool supports: split, then IndexSplitter <src> -d <seg> (usage text IndexSplitter.java:58, code :100-107; upstream TestIndexSplitter.java:97-98 splits and then removes the segment this way), then an IndexWriter on the source deletes the unreferenced files, then the crash hits before the copies are on disk.
  • File-system dependence (not tested). On ext4 with delayed allocation, a new file whose data was never written back can come back empty after a crash; where Files.copy clones extents (copy_file_range on XFS or btrfs), the copy may survive. Only the crash model was used, on ext4; no real power cut was induced.
  • Likelihood. Rare: the experimental tool plus a machine-level crash in a short window; nobody has reported it since 2009.

Minimal repro

One JUnit test in lucene/misc: an index with one segment of one empty document is split into a directory whose file system records every fsync, and the test asserts that every segment file of the destination's commit was fsynced. It simulates no crash, so it is deterministic. It fails with assertions on and off.

TestIndexSplitterFsync.java (one test)76 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.misc.index;

import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.FileSystem;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.attribute.FileAttribute;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.SegmentInfos;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.tests.mockfile.FilterFileChannel;
import org.apache.lucene.tests.mockfile.FilterFileSystemProvider;
import org.apache.lucene.tests.util.LuceneTestCase;

@LuceneTestCase.SuppressFileSystems("*")
public class TestIndexSplitterFsync extends LuceneTestCase {

  public void testSplitFsyncsCopiedFiles() throws Exception {
    Path src = createTempDir("src");
    try (Directory dir = FSDirectory.open(src);
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      w.addDocument(new Document());
    }
    Path dest = createTempDir("dest");
    Set<String> fsynced = new HashSet<>();
    FilterFileSystemProvider fs =
        new FilterFileSystemProvider("fsynctracking://", dest.getFileSystem()) {
          @Override
          public FileChannel newFileChannel(
              Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs)
              throws IOException {
            return new FilterFileChannel(super.newFileChannel(path, options, attrs)) {
              @Override
              public void force(boolean metaData) throws IOException {
                super.force(metaData);
                fsynced.add(path.getFileName().toString());
              }
            };
          }
        };

    new IndexSplitter(src).split(fs.wrapPath(dest), new String[] {"_0"});

    Set<String> notFsynced;
    try (Directory dir = FSDirectory.open(dest)) {
      notFsynced = new TreeSet<>(SegmentInfos.readLatestCommit(dir).files(false));
    }
    notFsynced.removeAll(fsynced);
    // expected: the split's commit references only fsynced files; actual: the copied files are not
    assertTrue(
        "segment files of the split's commit never fsynced: " + notFsynced, notFsynced.isEmpty());
  }
}
Command
# $REPRO holds TestIndexSplitterFsync.java; $LUCENE is a Lucene checkout
cp $REPRO/TestIndexSplitterFsync.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE
./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestIndexSplitterFsync -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
Every segment file of the split's commit has been fsynced, as for any Lucene commit: the test passes.
Observed
AssertionError: segment files of the split's commit never fsynced: [_0.cfe, _0.cfs, _0.si]; only segments_1 and the directory were fsynced.

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

More files, environment, full output308 words

Public API only: IndexSplitter, SegmentInfos.readLatestCommit and the test-framework mockfile classes. split() takes a Path and opens its own FSDirectory, so MockDirectoryWrapper cannot see its writes; the test wraps the destination path in a FilterFileSystemProvider whose FileChannel.force records the file name, and @SuppressFileSystems("*") keeps the framework's own mock file systems out. Two limits: only the destination is wrapped, so the test's Files.copy crosses file-system providers and copies through streams rather than the JDK's native same-file-system copy (neither fsyncs: the independent reproducer's same-provider tracker and its no-wrapper flight-recorder check see no force on the copies either); and it checks the commit's segment files (files(false)), not segments_1 itself. Seeds DEADBEEF and 5EED1234 (5 iterations) fail the same way; the seed sets only the framework's randomization (codec, locale and similar).

checkGoogleJavaFormat rejects its one unused import, java.nio.file.FileSystem; that is the only format issue, and the version without it was run too (the failure is then reported at line 72 instead of 73).

What a real crash does next is not part of the test. In Lucene's crash model (the same split, with the 3 copies left empty, zero-filled or deleted), opening the destination fails with CorruptSegmentInfoException on _0.si on main, and with CorruptIndexException on 10.5.2 and 9.12.3.

Environment

lucenemain 645d4fe14a20 and 10.6.0: fails, assertions on and off; 10.5.2 and 9.12.3: fails; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (assertions off: the same)
TestIndexSplitterFsync > testSplitFsyncsCopiedFiles FAILED
    java.lang.AssertionError: segment files of the split's commit never fsynced: [_0.cfe, _0.cfs, _0.si]
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C2ADA7351EE6702E]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.misc.index.TestIndexSplitterFsync.testSplitFsyncsCopiedFiles(TestIndexSplitterFsync.java:73)

# repro harness, the same message: main -ea, main -da, 10.6.0 -ea, 10.6.0 -da, 10.5.2 -ea, 9.12.3 -ea (1 run, 1 failed each)
# with the fix: the test passes (gradle, assertions on and off)

Why it looks unintended

  • Lucene uses Directory.sync "to prevent a machine/OS crash from corrupting the index", and IndexWriter.commit "syncs all referenced index files".
  • Every other code path that publishes a segments_N over files it wrote or copied syncs them first (IndexWriter, ReplicaNode).
  • The tool's javadoc lists its limits but not durability; the 2009 issue that added it never mentions fsync.
The full argument256 words
  • The commit contract. Directory.java:105-108: sync "Ensures that any writes to these files are moved to stable storage (made durable)." and "Lucene uses this to properly commit changes to the index, to prevent a machine/OS crash from corrupting the index." IndexWriter.java:4188-4191: commit "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."
  • Lucene's crash model. MockDirectoryWrapper.java:512: "Simulates a crash of OS or machine by overwriting unsynced files."
  • Every other publisher syncs first. IndexWriter.startCommit (IndexWriter.java:5992-5993) and ReplicaNode (:364-373). Among the non-test SegmentInfos.commit callers, split() is the only one that writes or copies segment data right before its commit: IndexSplitter.remove and CheckIndex's exorcise only rewrite the commit over files already in the index, and IndexRearranger commits segments that its own IndexWriter has just committed and closed (IndexRearranger.java:126-130, :170-173).
  • No carve-out. The class javadoc (IndexSplitter.java:35-44) lists the tool's limits (whole segments only, FSDirectory only, segments are easy to remove by accident), not durability. The IndexWriter.java:1513-1516 note that offline splitting tools are used "at your own risk!" is about breaking up atomically added document blocks. Upstream TestIndexSplitter checks only the split result in-process.
  • History: an omission. LUCENE-1959 (#3034, 2009) never mentions fsync, sync, crash or durability (body and all comments searched); the tool went in as "Progress not perfection!". None of the 84 commits that touch the file (all branches, its four paths, the introducing commit included) added or removed a sync.
The devil's-advocate check150 words

The strongest case for "intended": IndexSplitter is an experimental command-line tool documented as "basic file-level copying" that promises no durability; SegmentInfos.commit promises only to write and sync the segments file, so callers must sync their own files; the crash state is synthetic.

Why it fails: the tool publishes a real Lucene commit point, with both directory fsyncs, and nothing says this commit is exempt from Lucene's commit rule; @lucene.experimental is about API stability. The narrow reading of SegmentInfos.commit only decides where the fix goes (in IndexSplitter, as IndexWriter and ReplicaNode do it). The crash state is Lucene's own model, and the repro asserts only the contract. Nothing found (javadoc, test, CHANGES, issue or list thread) calls non-durable split output intended; every verifier rejected the intended reading, and those that rated its strength called it moderate. Maintainers may still treat it as a small robustness fix to an experimental tool.

Suggested fix

Recommended A. Sync the copied files before the commit

Collect the names of the files split() copies and call destFSDir.sync(copied) before destInfos.commit(destFSDir), the step IndexWriter and the NRT replicator already take: three statements and a comment, plus two imports. The repro passes, as do TestIndexSplitter (assertions on and off) and all 317 lucene/misc tests. Workaround today: run sync after a split.

    List<String> copied = new ArrayList<>();
    for (String n : segs) {
      // ... build the destination's SegmentCommitInfo, unchanged ...
      for (final String srcName : files) {
        // ... resolve srcFile and destFile, unchanged ...
        Files.copy(srcFile, destFile);
        copied.add(srcName);
      }
    }
    // the commit below must only reference files whose bytes are on stable storage
    destFSDir.sync(copied);
    destInfos.changed();
    destInfos.commit(destFSDir);
All options and trade-offs4 options ยท 482 words

One fix was built and run through the module's tests; the alternatives were not run.

A. Sync the copied files before the commit (validated by the checks) (recommended)

In IndexSplitter.split, collect every copied name and call destFSDir.sync(copied) before destInfos.commit(destFSDir) (shown condensed; the patch also adds the ArrayList and List imports). FSDirectory.sync fsyncs each named file; the commit's own directory fsyncs then make the names durable. The copied names are all of SegmentCommitInfo.files(): the segment's files plus any live-docs, field-infos and doc-values update files.

    List<String> copied = new ArrayList<>();
    for (String n : segs) {
      // ... build the destination's SegmentCommitInfo, unchanged ...
      for (final String srcName : files) {
        // ... resolve srcFile and destFile, unchanged ...
        Files.copy(srcFile, destFile);
        copied.add(srcName);
      }
    }
    // the commit below must only reference files whose bytes are on stable storage
    destFSDir.sync(copied);
    destInfos.changed();
    destInfos.commit(destFSDir);

Trade-offs

One fsync per copied file, once per split, in an offline tool. No behaviour change otherwise.

What the checks validated

  • Scratch worktree of main 645d4fe14a20 (removed afterwards), seed DEADBEEF: stock, the test fails through gradle (assertions on and off); with A it passes, together with upstream TestIndexSplitter, with -Ptests.asserts=true and false.
  • All of :lucene:misc:test with A (assertions on): 317 tests, 9 skipped, 0 failures.
  • :lucene:misc:checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass with A and the test (without its unused import); the build's optional eclint check is not configured on the host and was skipped.
  • Earlier, with the same fix shadowed in: the hunt's, the independent reproducer's and the cross-model reviewer's tests, a no-wrapper flight-recorder check (then forcing the three copied files) and upstream TestIndexSplitter pass.

B. Copy through the Directory API, then sync

Copy with destFSDir.copyFrom(fsDir, srcName, srcName, IOContext.DEFAULT) instead of Files.copy, then sync as in A. It would also answer the request deferred in LUCENE-1959 to use the Directory abstraction instead of java.io.

Trade-offs

A larger change that replaces the JDK's file copy with Lucene's stream copy; the sync is still needed, so it does not replace A. Not run.

C. Make SegmentInfos.commit sync every referenced file

Fix every caller at once inside the core method.

Trade-offs

Not recommended. IndexWriter never calls SegmentInfos.commit (it calls prepareCommit, IndexWriter.java:5974, syncs the files itself, :5992-5993, then calls finishCommit, :4295), so it would not be affected, but ReplicaNode would sync twice: it runs dir.sync(indexFiles) and then infos.commit(dir) (ReplicaNode.java:365, :373). Putting the sync into prepareCommit or finishCommit instead would double-sync in IndexWriter. It would also break split() as written: the SegmentInfo that split() builds (IndexSplitter.java:118-131) never gets its file set, so SegmentInfo.files() throws IllegalStateException ("files were not computed yet", SegmentInfo.java:206-212). Read, not run.

D. Document that the destination is not durable

A javadoc note telling callers to sync the destination themselves.

Trade-offs

Not recommended: it leaves a commit point that breaks Lucene's own commit rule.

Downstream note

Downstream, a public code search of the default branches found no use of Lucene's IndexSplitter in Elasticsearch, OpenSearch or Solr; Solr splits shards with its own SolrIndexSplitter.

How it was found

Code reading while building a file-system crash model of Lucene's commit protocol: listing every code path outside IndexWriter that publishes a commit showed that IndexSplitter.split never syncs the files it copies. A JUnit test with an fsync-recording file system confirmed it on the stock jars.

The method in full146 words

Method: code reading (non-formal), in a hunt on commit and crash recovery.

  • The hunt built one file-system crash model for Lucene's commit protocol: file data is durable only after its own fsync, a new name only after a directory fsync, renames are atomic. A crash-state enumerator and a TLA+ model checked IndexWriter, NRTCachingDirectory and PersistentSnapshotDeletionPolicy workloads against it; they did not cover IndexSplitter.
  • To list every code path outside IndexWriter that publishes a commit point, the finder read the callers of SegmentInfos.commit and saw that IndexSplitter.split copies files and commits without syncing them.
  • It wrote a JUnit test on the stock jars: a file system that records FileChannel.force per file, plus a post-crash image built under the model's rules. The test failed on main, 10.5.2 and 9.12.3 and passed with the fix sketch. It was later cut down to the one test shown here.

Prior reports, history and checks

Prior reports

No prior report: GitHub issues and PRs, the JIRA archive, the Lucene mailing lists, downstream repos and the web (2026-10-26), refreshed on GitHub 2026-10-31. Related but different: open #7044 (LUCENE-5982), misc index tools should take the write lock.

Searches5 searches

Related: #7044 / LUCENE-5982 ("Tools in misc/ like index splitters need to obtain write.lock", open since 2014), the same tool family and the same worry about accidental corruption, but it is about a concurrent writer, never mentions fsync or crashes, and its fix would not fix this. #2120 / LUCENE-1044 and #4491 / LUCENE-3418 set up and repaired fsync on commit for IndexWriter and are precedent for the contract. The Lucene.NET port has the same copy-then-commit code, with no report there.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2633 GraphQL searches (IndexSplitter with fsync, sync, crash, durable, corrupt, power, Files.copy; "not fsynced"; SegmentInfos.commit with fsync; misc tools write.lock) plus a scripted prior-report searchno report; 13 IndexSplitter items, all about other defects; #7044 related
Open PRs (apache/lucene)2026-10-26, refreshed 2026-10-31touched files of all 413 open PRs; then the 55 open PRs updated since 2026-10-26none touches IndexSplitter.java; #16571 touches SegmentInfos.java with no sync or commit change
JIRA archive and mailing lists2026-10-26JIRA REST queries and the full comments of 6 issues; dev@, java-user@, issues@, commits@ and Solr lists, 4 threads read in fullno durability discussion of IndexSplitter, LUCENE-1959 included
Downstream repos and the web2026-10-26apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and GitHub-wide; 6 web searchesno report; the Lucene.NET port has the same pattern
GitHub refresh (apache/lucene)2026-10-318 issue/PR queries; fsync items updated since 2026-10-26nothing new

History

Present since the tool was added in 3.0.0 (LUCENE-1959, 2009); none of the 84 commits (all branches) that touch the file added a sync. It fails on 13 releases from 3.0.0 to 10.5.2, on 10.6.0 and on main. Not a regression.

Versions tested7 rows

Introduced by 2fc8e01d9e1 (svn r823153, 2009-10-08), "LUCENE-1959: add IndexSplitter tool to pull segment files out of an index into another" (JIRA LUCENE-1959, migrated as #3034; svn era, no PR). The first version copied with a FileInputStream/FileOutputStream loop and then committed, syncing only the segments file. Later rewrites of the copy and commit lines (LUCENE-2805, LUCENE-4055, LUCENE-5945's switch to Files.copy, LUCENE-9600's move to org.apache.lucene.misc.index in 9.0.0) kept the omission.

Before 4.8.0 (LUCENE-5588) Lucene fsynced no directories, IndexWriter included, so for 3.0-4.7 the "names are durable" half of the crash picture is the same assumption IndexWriter relied on then. Minor releases not listed were not run; their code is unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
2.9.0, 2.9.4 no classno IndexSplitter (the port fails to compile only at the IndexSplitter lines)
3.0.0 (first release with the tool) affected_1.cfs never fsynced; the crash image fails to open (read past EOF)
3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0 affectedthe copied files never fsynced; the crash image fails to open (fsync tracer)
9.12.3, 10.5.2 affectedthe hunt's test and the tracer fail; the one-test repro fails
10.6.0 (the latest release) affectedthe one-test repro fails, assertions on and off
main 645d4fe14a20 (and ea57360912ed) affectedthe one-test repro fails through gradle and the harness, assertions on and off
main + fix A not affectedthe repro passes; all 317 lucene/misc tests pass

Independent checks

An independent reproducer, cross-model reviews (OpenAI GPT-5.6) with two different lenses, an intended-behaviour devil's advocate and an adjudication (confirmed, high) all found it real; each ran a repro, except one reading-only review, for which a separate run re-ran the original test.

Checks and verdicts239 words

Each check had its own lens; the adjudication re-ran the reproducer's class with a new seed. The registry's credibility audit (2026-10-26) kept the row.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer real, high for the mechanismOwn 3-test class fails 2 of 3 on main (assertions on and off), 10.5.2 and 9.12.3, every iteration of a 5-iteration run; the crash image fails to open in all three damage modes; the addIndexes control passes; a no-wrapper flight-recorder check shows only the directory and pending_segments_1 forced.
cross-model review, refute lens real, highRan its own 2-test class (fails 1 of 2 under three settings while its direct Directory.sync control passes); with the fix 2 of 2 and upstream TestIndexSplitter pass. Intended argument moderate.
cross-model review, works-as-designed lens real, mediumReading only; found no documented carve-out. A separate run re-ran the hunt's test: it fails, and passes with the fix.
intended-behaviour devil's advocate real, low severityBuilt the case for intended (experimental tool, narrow SegmentInfos.commit, synthetic crash) and rejected it; re-ran the hunt's test on main and 10.5.2.
adjudication confirmed, highRe-ran the reproducer's class with seed 5EED1234: 2 of 3 fail with assertions on and off, control passing; listed the caveats (no real crash induced, implicit contract, destination only).
security screen noneMachine-level crash only; a JVM halt right after the split left 5 of 5 destinations intact.

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

Finding record

LFV-191, snapshot of 2026-11-06

Registry title
IndexSplitter.split publishes a durable commit in the destination index without fsyncing the segment files it copied, so an OS crash or power loss soon after a split can leave a destination whose newest commit references empty files and cannot be opened
Area
misc / index tools (IndexSplitter.split, SegmentInfos.commit durability)
Priority
Low, rank 910: IndexSplitter never fsyncs the copied segment files, so an OS crash soon after a split can leave an unopenable destination, but it is a rarely used tool and the source stays intact.
Impact class
index unusable
Impact note
Operators who run the @lucene.experimental IndexSplitter command-line tool from lucene/misc (opt-in and rarely used; the default IndexWriter path is not affected) and whose machine then has an OS crash, kernel panic or power loss before the kernel writes the copied files back (about 30 s by default on Linux) can be left, at any index size and in every release since 3.0.0, with a split destination whose newest commit references empty or damaged segment files, so DirectoryReader.open fails (in Lucene's own crash model; no real power cut was induced, and whether a real crash loses the copies depends on the file system); the source index is untouched, so the destination can normally be re-created, and data is lost only if the operator has already removed the segment from the source with 'IndexSplitter <src> -d' and an IndexWriter has since deleted the source's unreferenced files.
Summary
You copy segments into a new index with IndexSplitter (new IndexSplitter(src).split(dest, segments), or the command line 'IndexSplitter <src> <dest> <segments>'), and the machine crashes or loses power shortly after the call returns. You expect what every Lucene commit is built for (Directory.sync javadoc: Lucene syncs files 'to prevent a machine/OS crash from corrupting the index'; IndexWriter.commit syncs all referenced files first): the destination's commit only references files whose bytes are on stable storage. Instead split() copies the segment files with Files.copy and never fsyncs them, and SegmentInfos.commit then fsyncs only the new segments_N and the directory, so the commit and the copied file names can survive the crash while the copied .si, .cfs and .cfe data does not, and opening the destination fails (CorruptSegmentInfoException on _1.si on main, CorruptIndexException on 10.5.2 and 9.12.3, in the simulated post-crash state).
Introduced
2fc8e01d9e1ae0eae040f27a2bcd044c23d27369 (svn r823153, 'LUCENE-1959: add IndexSplitter tool to pull segment files out of an index into another', committed by mikemccand from a patch contributed on JIRA LUCENE-1959, migrated as apache/lucene#3034; svn era, no PR) [read: blame on main and git log --all -G'sync|fsync|force\(' over all four historical paths of IndexSplitter.java in the full-history clone: 0 of the 84 commits that touch the file add or remove a sync; the first version copied with a FileInputStream/FileOutputStream loop and then called destInfos.commit(destFSDir), which synced only the segments file] [exec: 2.9.0 and 2.9.4 have no IndexSplitter (the 3.x port fails to compile only at the IndexSplitter import and use), 3.0.0 fails]; later in-place rewrites of the copy and commit lines (LUCENE-2805, LUCENE-4055, LUCENE-5945 switched to Files.copy, LUCENE-9600 moved the class to org.apache.lucene.misc.index) kept the omission (2009-10-08)
First affected version
3.0.0 [exec] (released 2009-11-25; contrib/CHANGES.txt 3.0.0 lists LUCENE-1959, and lucene-misc-3.0.0.jar contains org/apache/lucene/index/IndexSplitter.class); 2.9.x and earlier have no IndexSplitter [exec: compile error only at the IndexSplitter lines on 2.9.0 and 2.9.4; the 2.9.1-2.9.4 releases come from the 2.9 branch, which never got the class]
Affected versions
every release from 3.0.0 through 10.6.0, and main ea57360912ed. [exec] The one-test repro fails on the 10.6.0 jars, -ea and -da (2026-10-31). 13 releases fail, each with the copied files never fsynced and a post-crash image that a reader cannot open: 3.0.0, 3.6.2, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 and 9.1.0 (version-portable main() repro with a libc-level fsync tracer), 9.12.3 and 10.5.2 (finder JUnit and tracer), plus main (JUnit and tracer, -ea and -da); in every version the control (IndexWriter.commit in the source directory) fsynced every referenced file, and with the fix shadowed the tracer passes on main; the independent reproduction also showed the missing forces on main, 10.5.2 and 9.12.3 with the JDK's own jdk.FileForce flight-recorder event and no file-system wrapper. [read] the other releases are inferred from unchanged code. Before 4.8.0 (LUCENE-5588) Lucene fsynced no directories, IndexWriter included, so for 3.0-4.7 the 'file names are durable' half of the crash image is the same assumption IndexWriter relied on then. The class is org.apache.lucene.index.IndexSplitter (lucene-misc) up to 8.11.4 and org.apache.lucene.misc.index.IndexSplitter since 9.0.0. Not fixed upstream: main and branch_10x 1309534296e3 have no sync [read]; 10.6.0 is executed above. All runs on aarch64 with JDK 25.0.3; old releases ran on that JDK too, which does not change which files Lucene asks to fsync.
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, read-only, plus the finder's search: a scripted prior-report search over the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, and the web; 33 GitHub GraphQL searches on apache/lucene and 11 on apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and GitHub-wide; JIRA REST queries with the full comments of LUCENE-1959, -3188, -5982, -3418, -1044 and -2919 scanned; extra solr-user@, users@solr, dev@solr and general@ list queries and 4 threads read in full; the touched files of all 413 open apache/lucene PRs, none of which touches IndexSplitter.java). Closest related, different defect: open #7044 / LUCENE-5982 (2014) 'Tools in misc/ like index splitters need to obtain write.lock' (same tool family and the same worry about accidental corruption, but about mutual exclusion with a writer; no mention of fsync or crashes, and its fix would not fix this); contract precedent: LUCENE-1044 (#2120) and LUCENE-3418 (#4491) for IndexWriter. The Lucene.NET port (IndexSplitter.cs) has the same copy-then-commit pattern, with no report there.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the one-test repro through gradle in a scratch worktree (assertions on and off; removed afterwards) and through the hunt repro harness (assertions on and off); the fix through gradle (the repro and TestIndexSplitter, assertions on and off; all of :lucene:misc:test, 317 tests; the module's Google Java Format, ECJ lint and forbidden-APIs checks). The release jars with the one-test repro: 10.6.0 (assertions on and off), 10.5.2 and 9.12.3. The hunt ran on main ea57360912ed (the same lines), with history runs on 13 releases from 3.0.0 to 10.5.2, and 2.9.0 and 2.9.4 (no IndexSplitter). Upstream main 2f424fd428da (2026-10-30) has no change to IndexSplitter.java or SegmentInfos.java since 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:35-44 (class javadoc: 'basic file-level copying', @lucene.experimental, warning about removing segments), :100-106 (remove, the '-d' path), :109-152 (split), :141-147 (Files.copy loop), :149-150 (destInfos.changed(); destInfos.commit(destFSDir); no sync)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:1025-1034 (commit = prepareCommit + finishCommit, 'Writes and syncs to the Directory dir'), :970 and :1007 (syncMetaData in prepareCommit and finishCommit), :605-630 (write syncs only pending_segments_N, at :620)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4188-4201 (commit javadoc: 'syncs all referenced index files ... will survive an OS or machine crash or power loss'), :5993 (directory.sync(filesToSync) in startCommit), :1513-1516 (offline splitting tools break up document blocks, 'at your own risk')
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:105-112 (sync javadoc: 'moved to stable storage', 'to prevent a machine/OS crash from corrupting the index')
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:364-373 ('fsync all index files we are now referencing', dir.sync(indexFiles), then infos.commit(dir))
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java (crash(): 'Simulates a crash of OS or machine by overwriting unsynced files')
  • SECURITY.md (Scope and Threat Model; Index files are trusted; Other out-of-scope reports; Reporting out-of-scope issues publicly; In-scope reports)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:109-152 (split; copy loop :141-147; commit :149-150)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:605-630, :966-972, :996-1023, :1025-1034 (write, prepareCommit, finishCommit, commit)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5974, :5990-5993 (startCommit: prepareCommit, then directory.sync(filesToSync))
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:364-373
Who is affected
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:44, :58, :100-107; lucene/misc/src/test/org/apache/lucene/misc/index/TestIndexSplitter.java:97-98
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:104-112
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4188-4201, :1513-1516, :5990-5993
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:512-513
  • lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:364-373
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:35-44
Suggested fix
  • lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:244-251, :295-297
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfo.java:206-212; lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:118-131
  • lucene/core/src/java/org/apache/lucene/store/Directory.java:180 (copyFrom)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4295, :5974, :5992-5993; lucene/replicator/src/java/org/apache/lucene/replicator/nrt/ReplicaNode.java:365, :373 (option C)

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

Repro files

About the diagrams

The alt text follows the scene's summary card as of 2026-10-31.

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.