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.
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.- Index 4 documents with numeric doc value
price= 10, 11, 12, 13 and commit; thenupdateNumericDocValue(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). - 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.cfsand_0.si. - Open and close an
IndexWriteron 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 withNoSuchFileException: _0_1.fnm. This happens in every executed release from 4.6.0 to 10.5.2 (FileNotFoundExceptionon 4.6.0) and on main. - 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
CheckIndexreports the copy clean. - 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.
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...).
- Segment and commit: an index is a set of segments; the commit file
segments_Nlists, per segment, which files belong to it. When anIndexWriteropens 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>).
- For each segment,
split()builds a newSegmentInfoon the destination (IndexSplitter.java:118-131) and thennew 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, andsplit()never sets them. - It still copies
infoPerCommit.files()(:142-147), which includes the update files (SegmentCommitInfo.java:283-304). - It commits (
IndexSplitter.java:149-150);SegmentInfos.writepersists exactly those three per-segment items (SegmentInfos.java:727-747), here empty. The generations are written, so a reader still finds_0_1.fnmby name and the copy opens. - Every release since 4.6.0: an
IndexWriteropened 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. - Main:
SegmentDocValuesProduceropens 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. - Hard deletes survive because
delGenis passed andfiles()adds the live-docs file (SegmentCommitInfo.java:290-293). - Core's own as-is copy,
IndexWriter.copySegmentAsIs(IndexWriter.java:3647-3705), carries all three items (:3677-3686) and assertscopiedFiles.equals(newInfoPerCommit.files())(:3701-3702), the invariantIndexSplitterbreaks.
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 ornew 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 withNoSuchFileException: _0_2.fnmon 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_62763db11b0a). - 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 ona9779ef3c193): aMUST_NOTrule 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 inSoftDeletesDirectoryReaderWrapperstops 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 withIndexSplitter <src> -dand 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).
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.
/*
* 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);
}
}
}
}
# 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
IndexWriteris opened and closed on the copy, the copy opens and readsprice=999. Actual:expected the copy to open, but got java.nio.file.NoSuchFileException: .../_0_1.fnm, with-eaand-da.
- 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.addIndexesas 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 anFSDirectory, 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:
copySegmentAsIssets the field-infos files, the update files and every overlay and assertscopiedFiles.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 mentionIndexSplitter. - LUCENE-3188 (#4261,
3bc70d2368d, 2011): "contrib/misc IndexSplitter creates indexes with incorrect SegmentInfos.counter", fixed as a bug; its test opened anIndexWriteron 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-518949684b82701, LUCENE-5618/5636c863a7251d5); the per-commit file sets were missed. No javadoc, CHANGES entry or test says updated segments are unsupported;TestIndexSplitternever 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.
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.
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 sourceFSDirectory.split()uses the newSegmentInfosonly to writesegments_Ninto the destination, andSegmentInfosnever reads a segment's directory (no.diruse inSegmentInfos.java), so it did not matter in any test. But the in-memory clone points at the source:SegmentCommitInfo.sizeInBytes()reads file lengths throughinfo.dir(SegmentCommitInfo.java:270-274), so it would measure the source's files; nosplit()path calls it (listSegments()calls it on the source's own infos,IndexSplitter.java:88). A maintainer may prefer a destination-boundSegmentInfofor 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
SegmentCommitInfolater is carried for free, as long asclone()copies it.
What the checks validated
- Gradle,
-Ptests.seed=DEADBEEF, assertions on and off: the new test andTestIndexSplitter5/5; the whole:lucene:misc:testsuite with the new test, 319 tests, 0 failures (9 skipped);:lucene:misc:check -x test(google-java-format, ecjLint, forbiddenApis) passes. - Repro harness on
a9779ef3c193with 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.
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).
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.
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 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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, apache/lucene-solr, apache/solr, two other search servers, Lucene.NET, GitHub-wide) | 2026-10-26 | 19 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-26 | the touched files of all 411 open PRs | none touches IndexSplitter.java |
| JIRA archive (LUCENE, SOLR) | 2026-10-26 | text ~ "IndexSplitter" (15 issues triaged, full comments of 14) and update-file phrasings | no report; LUCENE-3188, LUCENE-5982 related |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 205 IndexSplitter mails over all years and 12 more queries | no report |
| Web and git history | 2026-10-26 | 10 web searches; git log --follow of IndexSplitter.java and -S searches for the setters | no report; the setters were only ever added to IndexWriter (LUCENE-8243) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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) | affected | default (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-open | the copy reads correctly until an IndexWriter opens it, then NoSuchFileException: _0_1.fnm (FileNotFoundException on 4.6.0) |
#16418 75eddfaff22 vs its parent 23c1810e431 | bisect | the parent reads 10,999,12,13; #16418 reads none,999,none,none; the same build with overlays off reads correctly |
| 4.5.1 | API absent | the 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 affected | the test 4/4 and the lucene/misc suite (319 tests) pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | 2/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 | real | Own 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 confidence | Intended reading weak; root cause agreed. A separate run re-ran the repro: 2/2 fail. |
| Intended-behaviour devil's advocate | real | Re-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 confidence | Re-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.0 | 16 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 | keep | Re-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 a9779ef3c193 | New 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 494 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,541 words
- 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 ona9779ef3c193, 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 mainea57360912ed, except the main refresh (the finder's test, 2 of 2 fail ona9779ef3c193). None of the 7 commits between the two touchesIndexSplitter.javaor the core files involved.
- 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-233lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:77-94lucene/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-45lucene/misc/src/test/org/apache/lucene/misc/index/TestIndexSplitter.java:38-43lucene/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-451lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3677-3686lucene/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)
- TestIndexSplitterDocValuesUpdate.java (JUnit test):
repro/LFV-408/src/org/apache/lucene/misc/index/TestIndexSplitterDocValuesUpdate.java(63 lines)
Made in parallel with this doc from the executed repro results (file lists and values of the stock run on main a9779ef3c193).
assets/LFV-408/bug.png(494 KB)
