All findings · Lucene finding · LFV-408 · indexsplitter-drops-dv-update-metadata

IndexSplitter drops a segment's doc-values update metadata: the copy breaks once an IndexWriter opens it, and reads wrong values on main

IndexSplitter.split copies a segment's doc-values update files but writes a commit that does not list them, so the next IndexWriter deletes them and the copy no longer opens (16 executed releases since 4.6.0, and main). On main the copy reads price 10, 999, 12, 13 as none, 999, none, none.

Summary

Priority
Medium, rank 155IndexSplitter deterministically writes a copy without its doc-values update metadata, so the copy is durably damaged in every release since 4.6, like LFV-199 but in a rarely used misc tool.
Impact
data corruption
Since
4.6.0 (LUCENE-5189, 2013)introduced 2013-11-01 · over 13 years
Affected
4.6.0 to 10.6.0 (10.6.0 by reading) and main; wrong values by default on main only (10.6.0 only with overlays enabled)
Status
documented
Found by
code reading, confirmed by a public-API JUnit

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

IndexSplitter copies whole segments into a new index. A segment that has had a doc-values update keeps the new values in extra files, and the commit must list them. The copy gets the files but not the list.

The source segment's 8 files (3 of its own, 5 holding the doc-values update) are all copied, but the destination commit lists only _0.cfe, _0.cfs and _0.si; an IndexWriter opened on the copy deletes the other 5, and the copy then fails with NoSuchFileException: _0_1.fnm. On main the copy reads price 10, 999, 12, 13 as none, 999, none, none.
What split() copies next to what the new commit lists, and what an IndexWriter then deletes. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 4 documents with numeric doc value price = 10, 11, 12, 13 and commit; then updateNumericDocValue(id:1, price, 999) and commit. On main the segment now has 8 files: its 3 own files and 5 that hold the update (_0_1.fnm, _0_1_Lucene90_0.dvd, .dvm, .dvp, .dvs).
  2. Copy it with new IndexSplitter(src).split(dest, new String[] {"_0"}). You expect the same documents and values, and a commit that lists the same 8 files. All 8 files are copied, but the new commit lists only _0.cfe, _0.cfs and _0.si.
  3. Open and close an IndexWriter on the copy. It deletes the unlisted files as unreferenced (5 on main; 4 on 10.5.2 and 3 on 9.12.3, whose update writes fewer files), and opening the copy then fails with NoSuchFileException: _0_1.fnm. This happens in every executed release from 4.6.0 to 10.5.2 (FileNotFoundException on 4.6.0) and on main.
  4. On main, where doc-values overlays are on by default, the copy is wrong even before a writer touches it: price reads none, 999, none, none, and CheckIndex reports the copy clean.
  5. A hard delete instead of the update is copied correctly. Soft deletes are doc-values updates, so they are hit too. split() never changes the source index.

Who is affected

Users of the experimental IndexSplitter tool in lucene/misc who copy a segment that still carries a numeric or binary doc-values update (not merged away), soft deletes included. Default settings, deterministic, any size. Releases: the copy breaks once a writer opens it. Main: wrong values at once. The source is never changed, and the tool is rarely used.

Mechanism, terms and numbers643 words

IndexSplitter.split(destDir, segments) (lucene/misc, @lucene.experimental) copies every file of each chosen segment, including the files that hold its doc-values updates, but writes a destination segments_N whose entry for the segment lists none of them: it rebuilds the per-commit SegmentCommitInfo from generation numbers only and never carries the field-infos files, the doc-values update files or (on main) the doc-values overlays. In every executed release from 4.6.0 to 10.5.2 and on main, the first IndexWriter opened on the copy deletes those files as unreferenced, after which the copy cannot be opened (NoSuchFileException: _0_1.fnm). On main, where overlays (#16418) are on by default, the copy is wrong before any writer touches it: an updated field reads only its newest sparse update, so price 10, 999, 12, 13 reads as none, 999, none, none, and CheckIndex reports the copy clean. Any numeric or binary doc-values update that the copied segment still carries (not yet folded in by a merge) triggers it, soft deletes included; default configuration, deterministic, no scale. The source index is never modified, and the tool is rarely used. It is the IndexSplitter half of LUCENE-8243 (2018), which fixed the same omission in IndexWriter.addIndexes(Directory...).

Terms

  • Segment and commit: an index is a set of segments; the commit file segments_N lists, per segment, which files belong to it. When an IndexWriter opens an index it deletes every file that no commit lists.
  • Doc-values update: IndexWriter.updateNumericDocValue / updateBinaryDocValue (and soft deletes, which use the same mechanism) change a doc value without reindexing. The new values go into extra files of a new generation (_0_1.fnm, _0_1_Lucene90_0.*), and the commit lists them for that segment.
  • Doc-values overlays (#16418, main; opt-in on branch_10_6): a set-only update writes a sparse delta with just the updated documents, and the commit records how to layer it over the original column. Without that record, the delta is read alone.
  • IndexSplitter: a lucene/misc command-line tool and class that copies chosen whole segments of an index into a new index (IndexSplitter <src> <dest> <segments>).

The bug

  1. For each segment, split() builds a new SegmentInfo on the destination (IndexSplitter.java:118-131) and then new SegmentCommitInfo(newInfo, delCount, softDelCount, delGen, fieldInfosGen, docValuesGen, id) (:132-140). That object starts with no field-infos files, no doc-values update files and no overlays, and split() never sets them.
  2. It still copies infoPerCommit.files() (:142-147), which includes the update files (SegmentCommitInfo.java:283-304).
  3. It commits (IndexSplitter.java:149-150); SegmentInfos.write persists exactly those three per-segment items (SegmentInfos.java:727-747), here empty. The generations are written, so a reader still finds _0_1.fnm by name and the copy opens.
  4. Every release since 4.6.0: an IndexWriter opened on the copy deletes the unlisted files (IndexFileDeleter.java:219-233, "init: removing unreferenced file"); the segment still needs _0_1.fnm, so the next open fails.
  5. Main: SegmentDocValuesProducer opens an overlay only when the commit has an entry for the field, and otherwise reads the field's newest generation file alone (SegmentDocValuesProducer.java:77-94). With overlays (IndexWriterConfig.java:115, default 8) that file holds only the updated document, so the others read as missing. Before overlays the newest file was a full rewrite of the column, which is why releases read correctly.
  6. Hard deletes survive because delGen is passed and files() adds the live-docs file (SegmentCommitInfo.java:290-293).
  7. Core's own as-is copy, IndexWriter.copySegmentAsIs (IndexWriter.java:3647-3705), carries all three items (:3677-3686) and asserts copiedFiles.equals(newInfoPerCommit.files()) (:3701-3702), the invariant IndexSplitter breaks.

The numbers

Source: price 10,999,12,13; its commit lists 8 files on main (7 on 10.5.2, 6 on 9.12.3). Copy: all files copied, commit lists _0.cfe, _0.cfs, _0.si. Main: the copy reads none,999,none,none. After an IndexWriter open/close: only _0.cfe, _0.cfs, _0.si, segments_1 and write.lock are left, and DirectoryReader.open throws NoSuchFileException: _0_1.fnm. Binary updates: v0, v1, v2, X, v4 reads none, none, none, X, none on main.

Exposure and likelihood293 words
  • Who is hit. Users of IndexSplitter (command line or new IndexSplitter(src).split(dest, segments)) who copy a segment that still carries a numeric or binary doc-values update (a segment written by a merge starts without any, IndexWriter.java:5343). Soft deletes count: in the follow-up probe a soft-deletes index split and then opened by a writer fails with NoSuchFileException: _0_2.fnm on main and on 10.5.2.
  • Default, not opt-in. The release symptom needs no setting. The wrong values are the default on main; on branch_10_6 (10.6.0, released 2026-10-29) overlays default to 0, so they need setMaxDocValuesOverlays(n > 0) (executed with 8 on branch_10_6 2763db11b0a).
  • No scale, no timing. Deterministic; 4 documents and one update are enough.
  • What is lost. Releases: the copy works until any writer touches it (adding documents, merging), then its update files and field infos are gone and it cannot be opened. Main: silent wrong values with a clean CheckIndex. In the follow-up probe (main, assertions off, re-run on a9779ef3c193): a MUST_NOT rule on an updated numeric doc-values field returns ids [0, 1, 3] on the copy instead of [1, 3]; after a soft delete and then a soft update on the same segment, the soft-deleted document is live again in the copy (with assertions on, an internal assert in SoftDeletesDirectoryReaderWrapper stops that open).
  • Recoverable in practice. split() only reads the source, so the copy can be made again, unless the segment was since removed from the source with IndexSplitter <src> -d and a writer deleted its files (not executed).
  • Reach. A rarely used experimental tool; a read-only code search found no common search server that calls it (Solr's shard split uses its own SolrIndexSplitter). No prior report was found (see Prior reports); the bug dates from 4.6.0 (2013).

Minimal repro

The test shows that IndexSplitter.split copies a segment with a doc-values update but writes a commit that does not reference the copied update files, so the first IndexWriter opened on the copy deletes them.

TestIndexSplitterDocValuesUpdate.java (JUnit test)63 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.nio.file.NoSuchFileException;
import java.nio.file.Path;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NumericDocValues;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestIndexSplitterDocValuesUpdate extends LuceneTestCase {

  public void testCopySurvivesIndexWriterOpen() throws Exception {
    Path src = createTempDir("src");
    Path dest = createTempDir("dest");
    try (Directory dir = FSDirectory.open(src);
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      Document doc = new Document();
      doc.add(new StringField("id", "0", Field.Store.NO));
      doc.add(new NumericDocValuesField("price", 10));
      w.addDocument(doc);
      w.commit();
      w.updateNumericDocValue(new Term("id", "0"), "price", 999);
    }
    new IndexSplitter(src).split(dest, new String[] {"_0"});
    try (Directory dir = FSDirectory.open(dest)) {
      new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec())).close();
      // expected: the copy still opens and reads price=999; actual: the writer deleted _0_1.fnm
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        NumericDocValues price = reader.leaves().get(0).reader().getNumericDocValues("price");
        assertTrue(price.advanceExact(0));
        assertEquals(999, price.longValue());
      } catch (NoSuchFileException e) {
        throw new AssertionError("expected the copy to open, but got " + e, e);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-408/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.misc.index.TestIndexSplitterDocValuesUpdate
Expected vs actual
Expected: after an IndexWriter is opened and closed on the copy, the copy opens and reads price=999. Actual: expected the copy to open, but got java.nio.file.NoSuchFileException: .../_0_1.fnm, with -ea and -da.

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

More files, environment, full output8 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Why it looks unintended

  • The javadoc promises "copying specific segments to another index" and lists its limits; updated segments are not among them.
  • The tool copies the update files itself; core's own segment copy also lists them in the commit and asserts it does.
  • LUCENE-8243 fixed this omission in IndexWriter.addIndexes as a bug (2018).
The full argument221 words
  • The contract. The class javadoc: "copying specific segments to another index" (IndexSplitter.java:36-37). Its listed limits: it cannot split a single segment, it does file-level copying so it needs an FSDirectory, and "You can easily accidentally remove segments from your index so be careful!" (:39-44). Nothing about doc-values updates or soft deletes.
  • The tool copies the update files (files(), :142-147), then commits an entry that does not own them.
  • Core enforces the invariant on the same operation: copySegmentAsIs sets the field-infos files, the update files and every overlay and asserts copiedFiles.equals(newInfoPerCommit.files()) (IndexWriter.java:3677-3686, :3701-3702).
  • LUCENE-8243 (#9290, commit 99364584fff, 2018, Bug Fixes in 7.4.0): "IndexWriter.addIndexes(Directory[]) did not properly preserve index file names for updated doc values fields". The same omission, fixed in core only; its description and comments never mention IndexSplitter.
  • LUCENE-3188 (#4261, 3bc70d2368d, 2011): "contrib/misc IndexSplitter creates indexes with incorrect SegmentInfos.counter", fixed as a bug; its test opened an IndexWriter on the split destination.
  • History is consistent with intended support (a reading, not a statement by the maintainers). When doc-values updates arrived, the maintainers passed the new generations through IndexSplitter (LUCENE-5189 49684b82701, LUCENE-5618/5636 c863a7251d5); the per-commit file sets were missed. No javadoc, CHANGES entry or test says updated segments are unsupported; TestIndexSplitter never applies a doc-values update.
The devil's-advocate check222 words

The strongest case for "intended": the tool is @lucene.experimental and does "basic file-level copying" (IndexSplitter.java:41), and TestIndexSplitter switches off the unreferenced-files check because "IndexSplitter.split makes its own commit directly with SIPC/SegmentInfos, so the unreferenced files are expected" (TestIndexSplitter.java:39-43). So the tool never promised a destination that survives a writer. The full case adds: the javadoc states no postcondition that the destination stays writable; the tool predates doc-values updates; on releases the copy reads correctly as long as no writer opens it; IndexWriter.addIndexes(Directory...) is the supported way to copy segments with updates; and the wrong-values symptom is in no release by default (10.6.0 only with overlays enabled).

Why it fails: @lucene.experimental says the API may change, not that the tool may write a commit that disowns files it needs. The test waiver is set on the source directory (:38), which keeps the files of segments removed with -d; those really are garbage. It came with LUCENE-4456 (cb402315131, 2012-10-03), before doc-values updates existed. LUCENE-3188 already treated an unusable destination as a bug. Every reviewer, including the cross-model reviews, rated the intended reading weak. What partly survives is reach: a maintainer may prefer to refuse segments with field updates rather than support them, which is a choice of fix, not a defence of today's silent success.

Suggested fix

Recommended Copy the whole per-commit object with clone() (validated)

Carry the whole per-commit state: replace the rebuild from generation numbers with destInfos.add(infoPerCommit.clone()) (+4/-27 lines, lucene/misc only). With it the repro and the whole lucene/misc suite (319 tests) pass, assertions on and off. Also validated: the copySegmentAsIs pattern, which needs one core setter made public.

SegmentCommitInfo infoPerCommit = getInfo(n);
// Copy the whole per-commit state (deletes, field-infos and doc-values update files and
// doc-values overlays), not only the generations: the destination commit must reference
// the update files it copies and describe how they combine with the segment's own files.
destInfos.add(infoPerCommit.clone());
// now copy files over
Collection<String> files = infoPerCommit.files();
All options and trade-offs4 options · 618 words

Two fixes were built and validated on a scratch worktree of main a9779ef3c193; two more are listed with their trade-offs.

Workaround until fixed: do not split segments that have doc-values updates or soft deletes. IndexWriter.addIndexes(Directory...) carries this metadata, but copies every segment of its source.

Do not bundle: the same constructor call also drops the in-memory SegmentInfo attributes and index sort (IndexSplitter.java:130-131); judged harmless by reading, because readers use the copied .si file, and not executed.

Copy the whole per-commit object with clone() (validated) (recommended)

In IndexSplitter.split, replace the new SegmentInfo and the SegmentCommitInfo built from generation numbers (IndexSplitter.java:116-140) with destInfos.add(infoPerCommit.clone()). clone() is public and deep-copies the field-infos files, the doc-values update files and the overlays (SegmentCommitInfo.java:427-451). lucene/misc only; +4/-27 lines (two imports go).

SegmentCommitInfo infoPerCommit = getInfo(n);
// Copy the whole per-commit state (deletes, field-infos and doc-values update files and
// doc-values overlays), not only the generations: the destination commit must reference
// the update files it copies and describe how they combine with the segment's own files.
destInfos.add(infoPerCommit.clone());
// now copy files over
Collection<String> files = infoPerCommit.files();

Trade-offs

  • The clone shares the source's SegmentInfo, whose directory is the source FSDirectory. split() uses the new SegmentInfos only to write segments_N into the destination, and SegmentInfos never reads a segment's directory (no .dir use in SegmentInfos.java), so it did not matter in any test. But the in-memory clone points at the source: SegmentCommitInfo.sizeInBytes() reads file lengths through info.dir (SegmentCommitInfo.java:270-274), so it would measure the source's files; no split() path calls it (listSegments() calls it on the source's own infos, IndexSplitter.java:88). A maintainer may prefer a destination-bound SegmentInfo for this reason (next option).
  • It also keeps the in-memory attributes and index sort that the current rebuild drops (Collections.emptyMap(), null).
  • New per-commit state added to SegmentCommitInfo later is carried for free, as long as clone() copies it.

What the checks validated

  • Gradle, -Ptests.seed=DEADBEEF, assertions on and off: the new test and TestIndexSplitter 5/5; the whole :lucene:misc:test suite with the new test, 319 tests, 0 failures (9 skipped); :lucene:misc:check -x test (google-java-format, ecjLint, forbiddenApis) passes.
  • Repro harness on a9779ef3c193 with the fix shadowed: the independent reproduction's independent test 5/0 and the follow-up probe (deny rule, soft deletes) 5/0, against 5/4 and 5/2 without it.
  • Earlier, on ea57360912ed: the finder's tests, the independent reproduction's 9 tests and the adjudication's re-run passed with it.

Rebuild like IndexWriter.copySegmentAsIs (validated)

Keep the rebuilt SegmentInfo on the destination directory, then call setFieldInfosFiles, setDocValuesUpdatesFiles and, for every overlay, setDocValuesOverlay, as IndexWriter.java:3677-3686 does. setDocValuesOverlay is package-private (SegmentCommitInfo.java:165), so it becomes public and @lucene.internal.

Trade-offs

Keeps the destination directory in the in-memory object and mirrors core. Costs a new public (internal) method on a core class, and three pieces of state to keep in sync if SegmentCommitInfo gains more. Using only the two setters that are already public is not enough: it removes the NoSuchFileException but still reads wrong values on main (the independent reproduction: 9 run, 6 failed).

What the checks validated

The new test 4/4; :lucene:misc:test 319 tests, 0 failures, assertions on and off; lucene/misc static checks plus core checkGoogleJavaFormat, ecjLintMain, forbiddenApisMain and renderJavadoc pass. Core tests were not run (only a visibility change there).

Refuse segments with field updates

Throw IllegalArgumentException in split() when infoPerCommit.hasFieldUpdates() (SegmentCommitInfo.java:325).

Trade-offs

Small and safe, but segments with soft deletes or any doc-values update can then no longer be split. Not run.

Javadoc only

Document that segments with doc-values updates or soft deletes are unsupported.

Trade-offs

Leaves a tool that reports success while writing a broken or wrong copy. Not run.

Downstream note

Downstream relevance: on main, an application that serves a split copy read-only loses the older values of updated fields, so a deny rule on such a field or a soft delete can stop working (executed probe); no common search server is known to call this tool.

How it was found

Code reading, then a test; no model or proof. An AI tool compared the per-segment state that IndexSplitter.split rebuilds with what segments_N stores, found the update file lists missing, and confirmed it with a public-API JUnit on the stock jars.

The method in full126 words

Non-formal finding. The hunt's hunt unit on the misc reorder and split tools had the contract "splitters partition without loss". While checking it, an AI tool (the finder) compared the SegmentCommitInfo that IndexSplitter.split rebuilds with the per-segment fields SegmentInfos.write persists, saw that the field-infos files, the doc-values update files and the overlays are never carried, and witnessed both symptoms on main, and the writer-open symptom on 10.5.2 and 9.12.3 (a public-API JUnit on the stock jars) and on 9.0.0, 8.11.4 and 7.0.0 (portable main() programs, where the copy still read 10, 999, 12, 13 before the writer).

The unit's formal checks (Z3 proofs, bounded-exhaustive doc-map checks, a merge-level differential) covered BPIndexReorderer, BpVectorReorderer, BPReorderingMergePolicy and MultiPassIndexSplitter, not IndexSplitter, and produced no signal here.

Prior reports, history and checks

Prior reports

No prior report found (117 logged searches of GitHub, JIRA, four mailing lists, downstream projects and the web, 2026-10-26). Closest: LUCENE-8243 (2018) fixed the same omission in IndexWriter.addIndexes(Directory...) and never mentions IndexSplitter.

Searches5 searches

Verdict: new (no report of the same bug, by anyone). Not fixed upstream either: IndexSplitter.java is the same blob (523e26a70d73) on upstream main a95c754ee003 (the newest fetched, 2 commits after a9779ef3c193) and branch_10_6 cb17f8f865c (2026-10-26), and no open PR touches it.

Closest items: #9290 / LUCENE-8243 (fixed 2018, 7.4.0), the same omission in IndexWriter.addIndexes(Directory...) / copySegmentAsIs; this bug is its IndexSplitter half. #16418 (and its 10.x backport #16554) added the overlays and updated copySegmentAsIs, not IndexSplitter. #7044 / LUCENE-5982 (open, 2014): misc splitter tools should take write.lock, a different defect about the same tool's safety. #4261 / LUCENE-3188 (2011): an earlier IndexSplitter destination bug, fixed as a bug.

Gaps: the GitHub REST search quota was saturated (covered by GraphQL search and local git history); searches are keyword-based; GitHub search does not index diffs, so open PRs were checked by touched files; one web query was throttled; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/lucene-solr, apache/solr, two other search servers, Lucene.NET, GitHub-wide)2026-10-2619 automated REST queries and 47 GraphQL queries (IndexSplitter with doc values, fnm, NoSuchFileException, unreferenced, SegmentCommitInfo, the setter names, LUCENE-8243)no report of this defect; closest LUCENE-8243 (sibling fix)
Open PRs (apache/lucene)2026-10-26the touched files of all 411 open PRsnone touches IndexSplitter.java
JIRA archive (LUCENE, SOLR)2026-10-26text ~ "IndexSplitter" (15 issues triaged, full comments of 14) and update-file phrasingsno report; LUCENE-3188, LUCENE-5982 related
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-26205 IndexSplitter mails over all years and 12 more queriesno report
Web and git history2026-10-2610 web searches; git log --follow of IndexSplitter.java and -S searches for the settersno report; the setters were only ever added to IndexWriter (LUCENE-8243)

History

Introduced with doc-values updates in 4.6.0 (LUCENE-5189, 2013): all 16 executed releases from 4.6.0 to 10.5.2 and main lose the files once a writer opens the copy. The wrong values came with #16418 (2026): main by default, 10.6.0 only with overlays enabled. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-5189 "add NumericDocValues field updates" (shaie): branch_4x merge 49684b82701 (2013-11-01, svn r1537832, no pull request), trunk 1120e46961b (2013-09-15). The commit that added per-commit update state to segments_N changed IndexSplitter.split in the same diff to pass only a generation. Later edits of the same rebuild kept the omission: the SegmentCommitInfo call (LUCENE-5618/5636 c863a7251d5, LUCENE-8341 fe83838ec37, LUCENE-9324 113043b1ed2) and the SegmentInfo call next to it (#12685 6677109ee60, which added info.getHasBlocks()). 49684b82701 itself was not built (JDK 25 javac rejects one of its 2013 sources).

The wrong values came with #16418 "Incremental doc-values updates" (75eddfaff22, 2026-08-13, jimczi), which added per-commit overlays and updated copySegmentAsIs but not IndexSplitter. A commit-level A/B with an identical IndexSplitter.class places it there.

Sibling fix: LUCENE-8243 (99364584fff, 2018, 7.4.0) fixed the same omission in IndexWriter.addIndexes(Directory...) and left IndexSplitter alone.

All runs on Linux aarch64 with JDK 25.0.3; release jars from Maven Central, SHA-1 checked.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 3 of 4 fail (gradle and harness, assertions on and off): wrong values, commit lists 3 files, NoSuchFileException after a writer
branch_10_6 2763db11b0a (10.6.0-SNAPSHOT) affecteddefault (overlays 0): values correct, writer-open fails; with setMaxDocValuesOverlays(8): none,999,none,none too
4.6.0 to 10.5.2 (16 executed releases) writer-openthe copy reads correctly until an IndexWriter opens it, then NoSuchFileException: _0_1.fnm (FileNotFoundException on 4.6.0)
#16418 75eddfaff22 vs its parent 23c1810e431 bisectthe parent reads 10,999,12,13; #16418 reads none,999,none,none; the same build with overlays off reads correctly
4.5.1 API absentthe program does not compile: updateNumericDocValue does not exist; a deletion control passes on 4.5.1 and 4.6.0
fix options A and B on main not affectedthe test 4/4 and the lucene/misc suite (319 tests) pass

Independent checks

An independent reproducer, a devil's-advocate checker, an adjudication and an audit confirmed it with their own runs. OpenAI GPT-5.6 reviews could only read the code (one said real, one not real for lack of execution); both agreed on the root cause, and separate re-runs failed 2 of 2.

Checks and verdicts401 words

All checks before this doc ran on main ea57360912ed, except the main refresh (the finder's test, 2 of 2 fail on a9779ef3c193); this doc re-ran the tests on a9779ef3c193 (none of the 7 commits in between touches the files involved). The GPT-5.6 reviews ran reading only, because their runner refused shell tools.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidate2/2 fail with assertions on and off on main; the writer-open test fails on 10.5.2 and 9.12.3 (JUnit) and on 9.0.0, 8.11.4 and 7.0.0 (portable main()); the clone() fix passes 2/2.
review, refute lens (OpenAI GPT-5.6, reading only) not real, low confidence (procedural)Could not execute; agreed on the root cause, found both symptoms in the code paths, rated the intended reading weak and suggested the copySegmentAsIs pattern. A separate run re-ran the repro: 2/2 fail.
Independent reproducer realOwn JUnit (numeric, binary, control): 5/4 with assertions on and off; 5/2 on 10.5.2 and 9.12.3; commit-file diagnostic, overlay toggle, CheckIndex clean; public-setter fix 9/6, clone() 9/0.
Works-as-designed review (OpenAI GPT-5.6, reading only) real, medium confidenceIntended reading weak; root cause agreed. A separate run re-ran the repro: 2/2 fail.
Intended-behaviour devil's advocate realRe-ran the repro: main 2/2 (assertions on and off), 10.5.2 1/2. Showed the test waiver is on the source directory and predates updates; found the LUCENE-3188 precedent; corrected the branch_10_6 claim (overlays default to 0 there).
Adjudication confirmed, high confidenceRe-ran the independent reproduction's suite with new seeds: 5/4 (on), 5/4 (off); with clone() 5/0; the finder's repro 2/2 (off).
History follow-up origin corrected to 4.6.016 releases fail the writer-open symptom; 4.5.1 lacks the API; a commit-level A/B places the wrong values at #16418. Moved the origin from LUCENE-5618 (4.9.0) to LUCENE-5189 (4.6.0).
Security screen not security-sensitive (strict re-evaluation)Executed probe: on main a deny rule on an updated field and a soft delete stop working on the copy; releases fail closed. Operator-only trigger, out of scope of SECURITY.md.
Credibility audit keepRe-ran the finder's JUnit with a fresh seed: main 2/2, 10.5.2 1/2; code read confirms the root cause.
This doc's runs reproduced on a9779ef3c193New 4-test JUnit: 3 of 4 fail through gradle (assertions on and off), 2 of 4 on 10.5.2 and 9.12.3; the independent reproduction's test 5/4 and the probe 5/2 on a9779ef3c193; both fixes validated.

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

Finding record

LFV-408, snapshot of 2026-11-06

Registry title
IndexSplitter.split writes each copied segment into the destination commit without its doc-values update metadata, so on main the copy silently reads wrong doc values, and in every release since 4.6.0 the first IndexWriter opened on the copy deletes the copied update files and the copy can no longer be opened
Area
misc / index tools (IndexSplitter.split) over core / index: SegmentCommitInfo per-commit doc-values update state
Priority
Medium, rank 155: IndexSplitter deterministically writes a copy without its doc-values update metadata, so the copy is durably damaged in every release since 4.6, like LFV-199 but in a rarely used misc tool.
Impact class
data corruption
Impact note
Only users of the @lucene.experimental IndexSplitter tool in lucene/misc (the command line or new IndexSplitter(src).split(dest, segments)) who copy a segment that has a numeric or binary doc-values update (soft deletes and soft updates included) are hit, every time and at any index size: in all 16 executed releases from 4.6.0 to 10.5.2 and on main, the first IndexWriter opened on the copy deletes the copied field-infos and doc-values update files as unreferenced, after which DirectoryReader.open fails with NoSuchFileException; on main with the default configuration (doc-values overlays default to 8; on branch_10_6, released as 10.6.0 on 2026-10-29, only with setMaxDocValuesOverlays(n>0)) the copy also reads wrong values before any writer touches it (every value not in the newest update reads as missing, a soft-deleted document becomes live again, and CheckIndex reports the copy clean); split() never modifies the source, so the copy can normally be re-created, unless the operator has since removed the segment from the source with 'IndexSplitter <src> -d' and a writer has deleted its files (that sequence was not executed).
Summary
You update a doc value in a segment (updateNumericDocValue, updateBinaryDocValue, or a soft delete), commit, and copy that segment to a new index with IndexSplitter.split, expecting the copy to hold the same documents and values and to stay a normal index. Instead split() copies all of the segment's files but writes a destination commit that records only the segment's generation numbers, not its per-commit field-infos files, doc-values update files or (on main) doc-values overlays. So on main the copy reads an updated field from its newest sparse update alone (price 10,999,12,13 reads as none,999,none,none), and in every release since 4.6.0 the first IndexWriter opened on the copy deletes the copied update files as unreferenced, after which the copy cannot be opened (NoSuchFileException: _0_1.fnm).
Introduced
49684b82701b4196c9ae029b0c4d45fd6efdcb4a (branch_4x merge of LUCENE-5189 'add NumericDocValues field updates', committed by shaie, svn r1537832, pre-GitHub, no PR; the trunk commit is 1120e46961b, 2013-09-15): the commit that added doc-values updates and the per-commit update file sets in segments_N changed IndexSplitter.split in the same diff to pass only a generation into the new per-commit object [read: git show of both commits in the full-history clone; exec: 4.5.1 cannot compile the trigger, 4.6.0 fails; 49684b82701 itself was not built because JDK 25 javac rejects one of its 2013 source files]; later edits of the same constructor call (LUCENE-5618/5636 c863a7251d5, LUCENE-8341 fe83838ec37, LUCENE-9324 113043b1ed2, #12685 6677109ee60) kept the omission. The wrong-values symptom (by default on main only; in 10.6.0 only with overlays enabled) came with 75eddfaff22eb46e8a68529952fb6787b1258854 (#16418 'Incremental doc-values updates', by jimczi), which added per-commit doc-values overlays that IndexSplitter does not carry [exec: commit-level A/B with an identical IndexSplitter.class: the parent 23c1810e431 reads correct values, 75eddfaff22 reads none,999,none,none, and the same build with setMaxDocValuesOverlays(0) reads correct values] (2013-11-01 (branch_4x; trunk 2013-09-15) for the writer-deletes-files symptom; 2026-08-13 for the wrong-values symptom (#16418))
First affected version
4.6.0 [exec] for the writer-deletes-files symptom (the first release with IndexWriter.updateNumericDocValue; 4.5.1 cannot compile the trigger, and a deletion-instead-of-update control passes on 4.5.1 and 4.6.0); the wrong-values symptom is in no release by default (main by default since 75eddfaff22; 10.6.0, released 2026-10-29, only with setMaxDocValuesOverlays(n>0), because branch_10_6 defaults overlays to 0)
Affected versions
every release from 4.6.0 through 10.6.0 (10.6.0 by reading, its branch_10_6 executed at 2763db11b0a) and main ea57360912ed. [exec] the writer-deletes-files symptom (an IndexWriter opened and closed on the copy deletes _0_1.fnm and the doc-values update files, then DirectoryReader.open throws NoSuchFileException, FileNotFoundException on 4.6.0) on 16 releases: 4.6.0, 4.8.1, 4.9.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (version-portable main() probe; 9.12.3 and 10.5.2 also with -da and with the finder's and the independent reproduction's JUnits; 7.0.0, 8.11.4 and 9.0.0 also with the finder's portable main()), on main commits 23c1810e431 and 75eddfaff22, on 10.6.0-SNAPSHOT branch_10_6 2763db11b0a, and on main ea57360912ed (-ea and -da); in every release the copy reads correct values until a writer opens it. [exec] the wrong-values symptom (the copy reads none,999,none,none instead of 10,999,12,13) only where doc-values overlays are on: main 75eddfaff22 (default 16) and ea57360912ed (default 8), and branch_10_6 2763db11b0a only with setMaxDocValuesOverlays(8); main with setMaxDocValuesOverlays(0) reads correctly. [read] the releases in between carry the same IndexSplitter construction. Not affected: 4.5.1 and older (no doc-values updates). Not fixed upstream: IndexSplitter.java is the same blob on upstream main cf398bfb142c and branch_10_6 cb17f8f865c (2026-10-26, read-only). All runs on aarch64 with JDK 25.0.3; release jars from Maven Central, SHA-1 checked.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), 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 GitHub (19 of its 30 planned apache/lucene queries; the REST search quota was saturated), the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 47 GitHub GraphQL searches on apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and GitHub-wide; JIRA REST (text ~ IndexSplitter: 15 issues triaged, full comments of 14 issues scanned); the mailing-list archive (205 IndexSplitter mails); the touched files of all 411 open apache/lucene PRs (none touches IndexSplitter.java); local git history; 117 logged queries, 30 hits triaged, none the same bug; follow-up re-check 2026-10-26: 'repo:apache/lucene IndexSplitter' still returns 13 issues, the most recently updated being #10004 on 2026-08-02, unrelated). Closest item, a sibling fix and not a report: #9290 / LUCENE-8243 (fixed 2018, first release 7.4.0) fixed the same omission in IndexWriter.addIndexes(Directory...) / copySegmentAsIs and left IndexSplitter unchanged; its description and 10 GitHub comments never mention IndexSplitter (re-read 2026-10-26). Context: #16418 (added the overlays and updated copySegmentAsIs, not IndexSplitter; no mention in its body, comments or reviews), #16554 (its 10.x backport), open #7044 / LUCENE-5982 (misc index-splitter tools should take write.lock; a different defect), #4261 / LUCENE-3188 (an earlier IndexSplitter bug: a destination that IndexWriter could not use was fixed as a bug).
Lucene commit tested
main a9779ef3c193 (2026-10-26): this doc's JUnit through gradle in a scratch worktree (assertions on and off) and through the hunt repro harness; the 10.5.2 and 9.12.3 release jars; the independent reproduction's test and the follow-up probe re-run on a9779ef3c193, stock and with the fix; both fixes validated with the whole lucene/misc suite. The follow-up's era-adapted programs ran on 16 releases from 4.6.0 to 10.5.2 and on commit-level builds of #16418 and its parent; the earlier checks ran on main ea57360912ed, except the main refresh (the finder's test, 2 of 2 fail on a9779ef3c193). None of the 7 commits between the two touches IndexSplitter.java or the core files involved.

Lucene and upstream sources

Finding record
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:35-45 (class javadoc: 'copying specific segments to another index', 'basic file-level copying', @lucene.experimental), :109-152 (split), :118-131 (new SegmentInfo with Collections.emptyMap() attributes and a null index sort), :132-140 (new SegmentCommitInfo from the generations only), :142-147 (copies infoPerCommit.files()), :149-150 (destInfos.commit)
  • lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:130 (public setDocValuesUpdatesFiles), :165 (package-private setDocValuesOverlay), :187 (public setFieldInfosFiles), :283 (files()), :427-451 (clone() deep-copies all three)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:726-745 (segments_N persists fieldInfosFiles, dvUpdatesFiles and overlays per segment)
  • lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:77-94 (a field without an overlay entry is read from its docValuesGen file alone)
  • lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:219-233 (init: 'removing unreferenced file')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3648-3702 (copySegmentAsIs carries setFieldInfosFiles, setDocValuesUpdatesFiles and every overlay, and asserts copiedFiles.equals(newInfoPerCommit.files()))
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:115 (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 8 on main); a Lucene clone origin/branch_10_6 2763db11b0a IndexWriterConfig.java:116 (= 0)
  • lucene/misc/src/test/org/apache/lucene/misc/index/TestIndexSplitter.java:39-43 (unreferenced-files waiver on the source directory)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:35-45 (class javadoc), :109-152 (split), :118-131 (new SegmentInfo), :132-140 (new SegmentCommitInfo), :142-147 (copies files()), :149-150 (commit)
  • lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:283-304 (files()), :290-293 (live docs)
  • lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:727-747 (per-segment field-infos files, update files, overlays)
  • lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:219-233
  • lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:77-94
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:115 (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 8), :334-342 ("Enabled by default")
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3647-3705 (copySegmentAsIs), :3677-3686, :3701-3702
Who is affected
  • a Lucene clone origin/branch_10_6 2763db11b0a lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:116 (= 0)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5343 (a merged segment starts with all update generations at -1); SegmentCommitInfo.java:325-326 (hasFieldUpdates)
Why it looks unintended
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:35-45
  • lucene/misc/src/test/org/apache/lucene/misc/index/TestIndexSplitter.java:38-43
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3677-3686, :3701-3702
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:130, :165, :187, :325, :427-451
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3677-3686
  • lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:270-274 (sizeInBytes reads info.dir); lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:88 (listSegments, source infos)
Prior reports
  • a Lucene clone: git rev-parse a95c754ee003:lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java = 523e26a70d73 (origin/main, 2026-10-26)

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

Repro files

About the diagrams

Made in parallel with this doc from the executed repro results (file lists and values of the stock run on main a9779ef3c193).

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.