The tool's main() calls reorderIndexDirectory, which opens the index for reading, then an IndexWriter in OpenMode.CREATE on the same directory inside try-with-resources, and calls addIndexes once per segment with a reordered view of it. There is no catch and no rollback, so any exception goes straight to close().
- An index on disk is its last commit: a
segments_Nfile that lists the segments. A writer opened withOpenMode.CREATEstarts a new, empty index in the same directory; until it commits, readers still see the old one. IndexWriter.close()commits whatever the writer holds, becausecommitOnCloseistrueby default;rollback()discards it and keeps the last commit.- After a commit, the default deletion policy keeps only that commit: the previous commit and the files only it used are deleted.
close() commits only the first, and 1,000 documents are left. Select the still to open it at full size. Generated with AI, checked against the code.- You run the reorder tool on a vector index,
BpVectorReorderer <indexDir> <field>, to renumber its documents in place. It opens a writer inOpenMode.CREATEon the same directory and adds the segments back one at a time, reordered. - One segment needs at least the tool's RAM budget, a tenth of the JVM's starting heap: with
-Xms8mthe test JVM started with a 32 MB heap, so the budget is 3.2 MB, and a 600,000-vector segment needs 4.6 MB. You expectNotEnoughRAMExceptionand an untouched index. - Instead try-with-resources closes the writer, and
close()commits by default. The new commit holds only the 1,000-document segment added before the failure, and the old commit is deleted: 601,000 documents become 1,000. - No big data is needed: with default options the tool also fails at the first segment of indexes written by Lucene 9.x or 10.x and of indexes with soft deletes or a parent field, and leaves 0 documents (10 to 12 before, 0 after, in the runs).
- The minimal test: 1 document, budget
Double.MIN_VALUE.reorderIndexDirectorythrowsNotEnoughRAMException, and the index then has 0 documents. Withrollback()on failure it keeps its 1 document.
Users of unreleased main or branch_11x who run this offline lucene/misc tool and whose run fails; the merge policy is not affected. Runs fail often: on indexes created by 9.x or 10.x, with soft deletes or a parent field, or with a segment at or over the budget (about 3.4 million vectors on 16 GB with HotSpot defaults).
Mechanism, terms and numbers660 words
BpVectorReorderer (lucene/misc, added for 11.0 by #14097) has a command line, main(indexPath, field), that renumbers the documents of an existing index in place so that documents with similar vectors get nearby doc ids. It calls the package-private reorderIndexDirectory, which opens a reader on the index, then an IndexWriter in OpenMode.CREATE on the same directory inside try-with-resources, and adds each segment back reordered with one addIndexes call per segment, with no catch and no rollback. If computing a doc map or addIndexes throws, for example the documented NotEnoughRAMException when a segment's doc-map metadata (8 bytes per vector) reaches the RAM budget (by default a tenth of the JVM's current heap), try-with-resources calls close(), which commits by default. The new commit holds only the segments added before the failure, and the default deletion policy deletes the original commit. In the minimal test 1 document becomes 0; through main() with default options 601,000 documents became 1,000 in a small-heap JVM, and indexes written by Lucene 9.12.3 or 10.5.2, or with soft deletes or a parent field, became empty. Unreleased: the tool is on main and on the 11.0 release branch branch_11x, and in no release, so it can be fixed on both before 11.0.0.
main(BpVectorReorderer.java:653-682) takes the index path, the field and three tuning options (--max-iters,--min-partition-size,--thread-count); it has no option for the RAM budget or the writer configuration. It callsreorderIndexDirectory(dir, executor)onFSDirectory.open(...)(:679-681).- The RAM budget is set in the constructor to a tenth of
Runtime.getRuntime().totalMemory()(:97), the JVM's current heap, which early inmain()is about the initial heap.computeValueMapthrowsNotEnoughRAMExceptionwhen a segment's vectors, at 8 bytes each (:642-646), need at least that budget (>=,:596-605);computeDocMap(:578-593) runs once per segment. reorderIndexDirectory(:684-697) opens a reader on the directory, thentry (IndexWriter writer = new IndexWriter(directory, iwc))withOpenMode.CREATEand an otherwise defaultIndexWriterConfig, and callswriter.addIndexes(SortingCodecReader.wrap(leaf, computeDocMap(leaf, null, executor), null))for each segment. EachaddIndexescall is transactional on its own (IndexWriter.java:3084-3086), not the loop.- When
computeDocMapthrows (NotEnoughRAMException; anAssertionErrorunder-ea) oraddIndexesthrows (anIllegalArgumentExceptionfor a 9.x/10.x-created, soft-deletes or parent-field index; anIOException), try-with-resources callsclose(). WithcommitOnClose(IndexWriterConfig.java:102)close()writes, waits for merges and commits (IndexWriter.java:1363-1369). - The new commit,
segments_2, lists only the segments added before the failure; the defaultKeepOnlyLastCommitDeletionPolicydeletes the original commit and its files. After the 9.12.3, 10.5.2, soft-update and parent-field runs the directory holds onlysegments_2andwrite.lock. - A failure that also breaks the commit (a disk that stays full) keeps the old index, because the commit itself fails. So the loss needs a failure before the commit that does not recur in it, which is what
NotEnoughRAMExceptionand theIllegalArgumentExceptions are.
| trigger | before | after the failed run |
|---|---|---|
minimal test: budget Double.MIN_VALUE | 1 document | 0 |
stock main(), default options, -Xms8m (budget 3.2 MB), segments of 1,000 and 600,000 vectors (the second needs 4.6 MB) | 601,000 | 1,000 |
stock main(), default options, index written by the 9.12.3 or 10.5.2 jars | 10 | 0 |
stock main(), default options, soft deletes (one soft update) / parent field | 12 / 10 | 0 / 0 |
| a later segment over budget, segments of 4, 4, 4 and 40 | 52 | 12 |
| one transient I/O fault at each of 225 directory calls (3 x 20 documents) | 60 | partial or empty at 207 points |
The 9.x/10.x and soft-deletes failures are the IllegalArgumentException of the sibling bug LFV-1023 (the tool's default IndexWriterConfig refuses those segments); refusing a parent-field index is arguably right, since reordering would break the blocks, but deleting it is not. An index that only configured a soft-deletes field, with no soft delete recorded, reorders fine (11 -> 11).
Exposure and likelihood290 words
- Who is exposed. Users of unreleased main or the 11.0 release branch
branch_11x(11.0.0-SNAPSHOT) who runBpVectorReorderer's command line on an index, or callreorderIndexDirectoryfrom the same package. Applications that useBPReorderingMergePolicyorcomputeDocMapwith their ownIndexWriterare not affected. - Default vs opt-in. The tool is opt-in, an expert tool run by hand. Once it runs, everything is default, and it has no option that avoids the loss.
- How often a run fails. With default options the tool's writer refuses every segment of a non-empty index created by Lucene 9.x or 10.x and every segment that carries a soft-deletes or parent field (the sibling bug LFV-1023 and the parent-field check); in every such run it was the first segment, and the index was wiped to 0 documents (executed on small non-empty indexes; the general form is read from
addIndexes' per-segment checks). Independently, the budget runs out on a segment whose vectors need at least the budget at 8 bytes each. HotSpot's default initial heap is 1/64 of physical memory (InitialRAMPercentage1.5625, printed on the test host), which gives about 3.4 million vectors on a 16 GB machine (computed, not run); other JVMs, container limits or-Xmschange it. The tool reorders each segment as a whole, so the limit is hit by large segments, such as those of a force-merged index (reasoning; upstream's dense-index test force-merges to one segment before callingmain()). - Effect. Durable: the original commit is gone, and only the exception tells the user. When a later segment fails, the partial index opens without error (12 of 52 documents). Recovery needs a backup or a re-index.
- Mitigating. Unreleased, offline, and loud (an exception). A user who copies the index first loses nothing.
One JUnit test in lucene/misc, in the class's package because reorderIndexDirectory is package-private (the upstream tests call it the same way; main() reaches it too): index 1 document with a 2-dimensional vector, set the RAM budget to Double.MIN_VALUE, expect NotEnoughRAMException, then count the documents. It fails on main with assertions on and off.
/*
* 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestBpVectorReorderFailureKeepsIndex extends LuceneTestCase {
// A failed reorder must keep the index's one document; today close() commits an empty index.
public void testFailedReorderKeepsIndex() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new KnnFloatVectorField("f", new float[] {1, 2}));
w.addDocument(doc);
}
BpVectorReorderer reorderer = new BpVectorReorderer("f");
reorderer.setRAMBudgetMB(Double.MIN_VALUE);
expectThrows(
AbstractBPReorderer.NotEnoughRAMException.class,
() -> reorderer.reorderIndexDirectory(dir, null));
try (DirectoryReader reader = DirectoryReader.open(dir)) {
assertEquals(1, reader.numDocs());
}
}
}
}
cp $REPRO/TestBpVectorReorderFailureKeepsIndex.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests TestBpVectorReorderFailureKeepsIndex -Ptests.seed=5EED1022
- Expected
- The reorder throws
NotEnoughRAMException, and the index still holds its 1 document. - Observed
- The reorder throws, and the index now holds 0 documents:
expected:<1> but was:<0>.
More files, environment, full output198 words
One test class, one method, single-threaded (executor null), formatted by the module's tidy task. It is the hunt's minimal repro with its comment reworded to fit one line. The seed randomizes the test environment (codec and per-field formats, similarity, locale, time zone, two size settings); the failure reproduced with every seed tried: gradle (seed 5EED1022) and the standalone runner (seeds DEADBEEF, FC1022A1, FC1022A2), assertions on and off, and the finder's 10-document version on six seeds.
expectThrows passes, so the reorder did throw NotEnoughRAMException; the failing assertion is the document count. The public main() path (601,000 -> 1,000; 9.x/10.x, soft-deletes and parent-field indexes -> 0) was executed separately and is in the explanation above.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT), gradle and a standalone JUnit runner |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (pure Java logic; nothing platform-specific) |
cp $REPRO/TestBpVectorReorderFailureKeepsIndex.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests TestBpVectorReorderFailureKeepsIndex -Ptests.seed=5EED1022
# the same failure with -Ptests.asserts=false
TestBpVectorReorderFailureKeepsIndex > testFailedReorderKeepsIndex FAILED
java.lang.AssertionError: expected:<1> but was:<0>
at __randomizedtesting.SeedInfo.seed([5EED1022:6D0CCC9A5B975BAC]:0)
...
at org.apache.lucene.misc.index.TestBpVectorReorderFailureKeepsIndex.testFailedReorderKeepsIndex(TestBpVectorReorderFailureKeepsIndex.java:44)
1 test completed, 1 failed
# the same message with -Ptests.asserts=false, and with the standalone runner (assertions on and off); passes with the fix- The documented outcome of too little RAM is a report: "a NotEnoughRAMException will be thrown" (
setRAMBudgetMB). - The sibling
BPReorderingMergePolicyskips reordering when RAM is short; its test checks that this "does not corrupt its content". - The tool owns the writer, so its user cannot roll back, and unlike CheckIndex
-exorcise("LOSES DATA") it warns of nothing.
The full argument269 words
- Contract. No javadoc promises failure atomicity in so many words.
main: "a path containing an index to reorder" (BpVectorReorderer.java:648-651), a permutation of its documents; the upstream test calls it "the index reordering tool" (TestBpVectorReorderer.java:353,:412).setRAMBudgetMB: "If not enough RAM is provided, a {@link NotEnoughRAMException} will be thrown" (AbstractBPReorderer.java:63-67). - The sibling handles the same error without damage.
BPReorderingMergePolicy: "When reordering doesn't have enough RAM, it simply skips reordering in order not to fail the merge" (BPReorderingMergePolicy.java:34-39);testReorderDoesntHaveEnoughRAMuses the sameDouble.MIN_VALUEbudget and "makes sure that reordering the index on merge does not corrupt its content" (TestBPReorderingMergePolicy.java:248-251). IndexWritersemantics. A CREATE writer replaces the old index only when it commits: old readers "won't see the newly created index until they re-open" (IndexWriter.java:103-107);addIndexes"does not commit a new segments_N file until all indexes are added" (:3084-3086). The half-added commit is made by the tool's own try-with-resources.- Lucene's other tools.
CheckIndex -exorcise: "LOSES DATA" and "Always make a backup" (CheckIndex.java:4645,:4652,:4856);IndexSplitter: "be careful!" (IndexSplitter.java:44). The in-placeIndexUpgraderalso closes its writer in try-with-resources, but on the existing index (defaultCREATE_OR_APPEND), so a commit after a failure still holds every document (read, not executed).BpVectorReorderercarries no warning, no@lucene.experimentaland no backup advice. - Tests and history. Upstream tests call
reorderIndexDirectory(TestBpVectorReorderer.java:175) andmain(:354,:413) on successful runs only. The comments and reviews of #13565, #13683, #14097 and #14117 never discuss a failing run.
The devil's-advocate check256 words
Strongest case (the refute-lens cross-model review rated it moderate; the devil's-advocate check built it in full and rated it weak): it is a research tool (in #13565 its author wrote "it would be easier to start with a tool that rewrites the index" to get an idea of the impact; CHANGES never mentions the command line, and even a successful run drops commit user data and any index sort, executed as a side observation and not claimed); OpenMode.CREATE is documented as "Creates a new index or overwrites an existing one" (IndexWriterConfig.java:58); close() committing is documented and was restored as the default on purpose (LUCENE-5871); and the minimal test needs the package-private method and a budget a command-line user cannot set.
Why it fails: nothing says research-only or "make a backup", main() is public in lucene/misc (not the sandbox), and Lucene's data-losing tools warn loudly. Granted, CREATE is documented to overwrite and an IndexWriter caller normally owns rollback; but here the tool picks CREATE and owns the writer's whole lifecycle, so the tool is the caller that should roll back. What CREATE is meant to put in place is the reordered index; the empty or partial commit is made by the tool's own try-with-resources after it has thrown. commitOnClose leaves rollback to the caller, and the caller here is Lucene's own tool, which gives its user no hook. Public main() with default options reaches it on ordinary indexes. Verdict: a defect. Residual risk: a maintainer may prefer to remove main() or document "back up first".
Recommended Roll back on failure; close (commit) only after every segment was added (validated)
Open the writer without try-with-resources; if adding any segment throws, call writer.rollback(), which keeps the original commit, and rethrow; call close() only after every segment was added (+12/-1 in one method). The test then passes, and so do the whole lucene/misc test suite (317 tests) and its static checks.
IndexWriter writer = new IndexWriter(directory, iwc);
try {
for (LeafReaderContext ctx : reader.leaves()) {
CodecReader codecReader = (CodecReader) ctx.reader();
writer.addIndexes(
SortingCodecReader.wrap(codecReader, computeDocMap(codecReader, null, executor), null));
}
} catch (Throwable t) {
try { writer.rollback(); } catch (Throwable t2) { t.addSuppressed(t2); }
throw t;
}
writer.close();All options and trade-offs4 options ยท 530 words
Option A was built and validated with gradle in a scratch worktree at 645d4fe14a20; options B, C and D were not built.
One method, reorderIndexDirectory in BpVectorReorderer.java (+12/-1): open the writer without try-with-resources; if adding any segment throws, call writer.rollback(), which discards the added segments and keeps the original commit, add a rollback failure as suppressed and rethrow; call writer.close() only after the loop. The logic is the finder's fix sketch with the comment reworded.
IndexWriter writer = new IndexWriter(directory, iwc);
try {
for (LeafReaderContext ctx : reader.leaves()) {
CodecReader codecReader = (CodecReader) ctx.reader();
writer.addIndexes(
SortingCodecReader.wrap(codecReader, computeDocMap(codecReader, null, executor), null));
}
} catch (Throwable t) {
try { writer.rollback(); } catch (Throwable t2) { t.addSuppressed(t2); }
throw t;
}
writer.close();Trade-offs
- The success path is unchanged:
close()still flushes, waits for merges and commits. - On failure the writer is rolled back and closed and the original exception is rethrown.
Throwableis caught so anAssertionError(under-ea) is covered too. - It does not fix LFV-1023: the tool still refuses 9.x/10.x and soft-deletes indexes, now without deleting them. Indexes already damaged are not restored.
- Open PR #15430 moves the class to core with this method unchanged; if it merges first, the fix moves with it.
- Validated for failures inside the segment loop (
computeDocMap,addIndexes), where every observed trigger fires. Not tested: theIndexWriterconstructor failing,rollback()itself failing, orclose()failing after every segment was added (its javadoc: on an exception "changes may have been lost").
What the checks validated
- Scratch worktree at
645d4fe14a20, gradle, JDK 25: the test fails on stock and passes with the fix, assertions on and off. ./gradlew :lucene:misc:checkwith the fix: formatting (checkGoogleJavaFormat),ecjLint,forbiddenApisand the whole module's tests (24 suites, 317 tests, 0 failures, 9 skipped), includingTestBpVectorReorderer(8 of 8) andTestBPReorderingMergePolicy(its two@Nightlytests were skipped; they do not call the changed method). With assertions off the three reorder test classes pass too.- Earlier, the same logic passed the finder's 10-document test, the upstream reorder tests (9 of 9) and the bounded-exhaustive harness with 0 violations of 483 checks (stock: 236).
Two lines: with commitOnClose off, close() means rollback() (IndexWriter.java:1363-1369), so any failure before the explicit commit keeps the old index.
Trade-offs
On success, merges still running after the explicit commit are aborted by the rollback in close() instead of being waited for and committed (addIndexes(CodecReader...) calls maybeMerge(), IndexWriter.java:3395), so the result may keep more segments than today (inferred, not run).
Leave the input alone, or swap the new index in only after a successful commit.
Trade-offs
The safest for the input, but it changes the command line's in-place contract and needs twice the disk space.
Document "back up the index first; a failed run can leave it empty or partial".
Trade-offs
No code risk, but the loss remains, and today's most common failures happen on ordinary indexes.
Downstream, this matters for any product or script that runs the tool automatically on indexes it manages: a failed run there deletes the index's documents instead of leaving it unchanged.
A failure-atomicity review read reorderIndexDirectory and ran probes on main: one injected I/O fault left a partial or empty index at 207 of 225 points, and a small-heap command-line run turned 601,000 documents into 1,000. A bounded-exhaustive single-fault harness then confirmed it (236 violations in 483 checks; 0 with the fix).
The method in full178 words
Method: code reading plus executed probes, then a bounded-exhaustive harness (non-formal tier: the first signal was code reading and executed probes, not a model).
- A failure-atomicity planning pass read
reorderIndexDirectory(try-with-resources around an in-placeCREATEwriter, defaultcommitOnClose) and ran probes on stock main: a single injected I/O fault at each of the 225 directory calls of an in-place reorder committed a partial or empty index at 207, and a stockmain()run in a JVM with a small initial heap turned 601,000 documents into 1,000. A skeptic pass reduced it to a first-segment failure. - The hunt then checked the real jars with a bounded-exhaustive harness against an executable failure-atomicity reference: all 30 small/over-budget layouts of 1 to 4 segments, every fault point one-shot and sticky, and three natural triggers (an index created by an earlier major, a soft-deletes field, a parent field). Stock: 236 violations of 483 checks; with the fix: 0.
- It wrote a 10-document JUnit, later reduced to the 1-document test above. The sibling refusal of 9.x/10.x and soft-deletes indexes (LFV-1023) was filed separately.
No prior report found (searched 2026-10-29): GitHub issues, PRs and commits, the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, downstream projects and the web. The PRs that added the tool (#13683, #14097) never discussed a failing run. Open PR #15430 moves the class to core with this method unchanged.
Searches4 searches
Verdict new. The nearest items introduced the tool, move it, or touch other parts of the file; none reports this failure.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the JIRA archive | 2026-10-29 | two runs of a scripted prior-report search (the tool's own terms; the generic close()/commitOnClose and OpenMode.CREATE + addIndexes mechanism), 51 planned queries each, 45 run (the 6 web queries of each run were done as the separate web leg below) | term-overlap hits only (run A 19, 1 medium: #16879; run B 1,638, 3 high: LUCENE-5377, #16625, #5316, and 129 medium, generic IndexWriter and addIndexes history); none names this tool or its failure handling |
| the web | 2026-10-29 | 8 web searches (the tool's name with exception, empty index, lost documents and bug; the generic close()-commits pattern) | nothing relevant |
| GitHub (apache/lucene) | 2026-10-29 | 20 manual searches (28 attempts, 8 re-run after rate limiting); the changed files of all 415 open PRs and of the 73 PRs updated since 2026-10-26; the git history of the file | #15430 (moves the class, method unchanged), #16847 and #15285 (other hunks), #16879 (performance) |
| Read in full, not the same bug | 2026-10-29 | every comment and review | #13565, #13683, #14097, #14117 (no failure discussion); LUCENE-5377 (a fixed 2013 addIndexes(Directory...) corruption bug); #5316 / LUCENE-4246 (generic close()-commits history); #16625 (a different rollback issue) |
Introduced with the tool by #14097 (2025-01-07); the method has not changed since. The test fails at that commit, the next commit to the file and main; the parent and 9.12.3, 10.1.0, 10.2.0 and 10.5.2 lack the tool. The 11.0 release branch branch_11x has the same file; no release has it.
Versions tested7 rows
Introduced by 02a09f6ab12 "HNSW BP reordering (#14097)" (Michael Sokolov, 2025-01-07), which added BpVectorReorderer with main and reorderIndexDirectory, and AbstractBPReorderer with NotEnoughRAMException. git blame gives it every line of the method; the later commits to the file, b7c7fe064ce (#14117) and 54283150a81 (#16255, comment typos), touch other lines. The same in-place pattern is in the final patch of the author's unmerged #13683. close() has committed by default since LUCENE-5871 (2014); that is the mechanism, not the defect. CHANGES lists #14097 in the 11.0.0 section.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | this doc's test fails (gradle, assertions on and off); the 10-document version fails with -ea and -da; passes with the fix |
branch_11x 603af870375 (2026-10-29) | read | the 11.0 release branch: the same BpVectorReorderer.java, AbstractBPReorderer.java, IndexWriter.java and IndexWriterConfig.java as 645d4fe14a20; the repro was not run there |
b7c7fe064ce (#14117) | affected | the 10-document version fails (10 -> 0) |
02a09f6ab12 (#14097) | introduced | the 10-document version fails with -ea and -da |
72d93de50c2 (parent) | no API | the repro does not compile: the classes do not exist |
| 9.12.3, 10.1.0, 10.2.0, 10.5.2 | no API | the repro does not compile |
branch_10x (decommissioned 2026-10-29), branch_10_6 (10.6.0, released), branch_10_5 | read | neither file exists (git ls-tree) |
Confirmed by an independent reproducer, executing reviews from another model family (OpenAI GPT-5.6), a devil's advocate, an adjudication and an audit, each with their own run. This doc's test and fix were re-run with gradle on main.
Checks and verdicts379 words
Every executing check wrote or ran its own test on the stock jars, with a passing control where one applies; the fix passes each of their tests.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 4-document test: 4 -> 0 with -ea and -da. main() on a soft-deletes index: 5 -> 0, with segments_2 and write.lock left (plus the test framework's extra0). A second-segment failure keeps 2 of 102. A high-budget control keeps 4 of 4; the fix makes its test pass. |
| cross-model review, refute lens | real, high | An independent reviewer from another model family (OpenAI GPT-5.6), executing. Own 3-document test: 3 -> 0 beside a passing control; public main() on a valid 200,000-document one-segment index in a small-heap JVM: 0 left. The fix passes its test and the upstream tests. Rated "CREATE overwrites" moderate and rejected it. |
| cross-model review, intended lens | real, high | Same reviewer family, executing. Own low-RAM test 3 -> 0 (-ea, -da); main() on a parent-field index 3 -> 0 while a plain-index control keeps 3. The fix passes the upstream tests. The cross-model reviews ran on the hunt's other main checkout ea57360912ed, where the files involved are unchanged. |
| devil's advocate | real, high | Built the strongest works-as-designed case and refuted it; its own public-API test (main() with default options on a parent-field index) goes 4 -> 0 with -ea and -da. |
| adjudication | confirmed, high | Re-ran the repro (10 -> 0, -ea and -da) and the intended-behaviour check's main() test (4 -> 0); no-noise gate passed. |
| audit | keep | Re-ran the repro with fresh seeds: fails with -ea and -da. Committer view: real and worth fixing before 11.0. |
| this doc | reproduces; fix validated | The 1-document test fails with gradle on 645d4fe14a20 (assertions on and off) and passes with the fix; the lucene/misc suite and static checks pass with it. |
| independent fact check (2026-10-30) | substantively correct | Re-ran the gradle command (the documented output, assertions on and off), the standalone runs and the fix (:lucene:misc:check passes). A true default-options command line: 601,000 -> 1,000, a soft-update index 12 -> 0, a parent-field index 10 -> 0; with the fix the index keeps 601,000. |
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 500 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,337 words
- Registry title
- BpVectorReorderer's in-place command-line reorder commits an empty or partial index over the original when reordering fails
- Area
- misc / index: BpVectorReorderer command line (main -> reorderIndexDirectory, an in-place rewrite through an OpenMode.CREATE IndexWriter and addIndexes)
- Priority
- Medium, rank 101: A failed in-place BpVectorReorderer command-line run deterministically replaces the original index with an empty or partial commit, which is severe data loss, but it stays Medium because it is an offline lucene/misc tool that exists only on unreleased main.
- Impact class
- data corruption
- Impact note
- Only users of unreleased main (11.0.0-SNAPSHOT) who run the offline lucene/misc BpVectorReorderer command line (or its package-private reorderIndexDirectory) on an index are hit, and only when the run fails, but then the index they asked it to reorder is durably replaced by an empty or partial one and the original commit is deleted (executed: 10 -> 0 documents after the documented NotEnoughRAMException; through main() with default options 601,000 -> 1,000 documents in a small-heap JVM, and 0 documents left for indexes written by 9.12.3 or 10.5.2 or carrying a soft-deletes or parent field); no scale is needed beyond a segment whose vectors need more than the default RAM budget, a tenth of the JVM's initial heap at 8 bytes per vector.
- Summary
- You run BpVectorReorderer's command line (main(path, field), which calls reorderIndexDirectory) to renumber the documents of an existing index in place, and expect that if the reorder fails you get the exception and the index still holds all its documents. Instead, when computing a doc map or addIndexes throws (for example the documented NotEnoughRAMException), the try-with-resources around the OpenMode.CREATE IndexWriter closes it, close() commits by default, and the new commit holds only the segments rewritten before the failure, while the original commit and its files are deleted. A 10-document index ends with 0 documents, and through main() with default options an index created by Lucene 9.x or 10.x, or one with a soft-deletes or parent field, is wiped to 0 documents.
- Introduced
- 02a09f6ab124e994fd1af42965617c8b5ec67bcc, apache/lucene#14097 'HNSW BP reordering' (by msokolov; author date = commit date 2025-01-07, merged to main 2025-01-07T14:41:35Z): it added BpVectorReorderer with main() and reorderIndexDirectory (an OpenMode.CREATE IndexWriter on the input directory in try-with-resources, one addIndexes per leaf, no rollback) and AbstractBPReorderer with NotEnoughRAMException and setRAMBudgetMB [exec: the repro fails at 02a09f6ab12 with -ea and -da and does not compile at its parent 72d93de50c2; read: git blame attributes every line of reorderIndexDirectory to it, unchanged since]. The same in-place pattern is already in the final patch of the author's earlier, unmerged PR #13683 'HNSW BP reorder tool'. Pre-existing mechanism, not the defect: close() commits by default (IndexWriterConfig.DEFAULT_COMMIT_ON_CLOSE, 872bd9145cd, LUCENE-5871, 2014-08-09). (2025-01-07)
- First affected version
- unreleased (main only, 11.0.0-SNAPSHOT; CHANGES.txt lists GITHUB#14097 in the 11.0.0 section; BpVectorReorderer and AbstractBPReorderer are absent from every 9.x and 10.x release and from branch_10x, branch_10_6 (10.6.0) and branch_10_5 [exec: 9.12.3, 10.1.0, 10.2.0, 10.5.2 do not compile the repro; read: git ls-tree]); the first affected release would be 11.0.0 unless it is fixed first
- Affected versions
- main from 02a09f6ab12 (2025-01-07) through 645d4fe14a20 (upstream main head, re-checked 2026-10-29) [exec: the repro fails 10 -> 0 documents at 02a09f6ab12 (-ea and -da), at the next commit to the file b7c7fe064ce (-ea) and at 645d4fe14a20 (-ea and -da); read: main() and reorderIndexDirectory are textually unchanged in between; the later commits b7c7fe064ce (#14117) and 54283150a81 (#16255) touch other lines]. Not affected, the API is absent: 9.12.3, 10.1.0, 10.2.0, 10.5.2 and the parent 72d93de50c2 [exec: compile error]; branch_10x 657014b3c57, branch_10_6 4ae946e5a9b (10.6.0 RC1) and branch_10_5 e85bb7b2c86 [read: git ls-tree; the finder's misc-jar listing of 10.5.2, the 10.6.0 RC1 and branch_10x agrees]. All runs on Linux aarch64 with JDK 25.0.3.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-29, re-checked; read-only; the prior-report record verdict new): two scripted prior-report searches of 51 planned queries each (the tool's own terms; the generic close()/commitOnClose and OpenMode.CREATE + addIndexes mechanism) over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 28 manual GitHub searches; the changed files of the 73 apache/lucene PRs updated since 2026-10-26 and the diff hunks of the three open PRs that touch BpVectorReorderer.java; every issue comment, review comment and review of #13565, #13683, #14097 and #14117; the git history of the file; 8 web queries. Related, not reports: #14097 (introduced the tool; nothing on failure handling), #13683 (its earlier unmerged version), #13565 (the motivating issue), open #15430 (BP Reordering Codec: moves the class to core with reorderIndexDirectory unchanged), open #16847 and #15285 (other hunks of the file), #16879, #15003, #15219, #15606, #16729, #14758, #14110 / #14153, LUCENE-4246 / #5316 (the generic close()-commits design history) and #16625 (a different IndexWriter rollback issue). Re-check: GitHub search for BpVectorReorderer issues and PRs returns only those items, no apache/lucene issue or PR matching 'reorder' was updated since 2026-10-28, and upstream main is still 645d4fe14a20, with BpVectorReorderer.java last changed by 54283150a81 (2026-06-14). Not fixed upstream.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28, the upstream main head when checked) with gradle in a scratch worktree (assertions on and off) and with the standalone JUnit runner; the introducing commit02a09f6ab12, the next commit to the fileb7c7fe064ce, and (compile error, the tool does not exist) its parent72d93de50c2and the releases 9.12.3, 10.1.0, 10.2.0 and 10.5.2. Indexes written by the real 9.12.3 and 10.5.2 jars were run through stockmain()on main. The fix was validated in the scratch worktree at645d4fe14a20: the test with assertions on and off, the wholelucene/misctest suite and its static checks. Re-checked 2026-10-30 by reading: upstream main716d6f216f5and the 11.0 release branchbranch_11x603af870375have the sameBpVectorReorderer.java,AbstractBPReorderer.java,IndexWriter.javaandIndexWriterConfig.javaas645d4fe14a20(the repro was not run onbranch_11x).
- Finding record
lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:97 (RAM budget = totalMemory()/10), 584-592 (computeDocMap), 596-605 (computeValueMap throws NotEnoughRAMException), 648-651 (main javadoc), 653-682 (main: no budget or writer option; FSDirectory.open and the call at 679-681), 684-697 (reorderIndexDirectory: OpenMode.CREATE writer in try-with-resources, one addIndexes per leaf)lucene/misc/src/java/org/apache/lucene/misc/index/AbstractBPReorderer.java:63-67 (setRAMBudgetMB: 'If not enough RAM is provided, a NotEnoughRAMException will be thrown')lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:35-38 (skips reordering when RAM is short); lucene/misc/src/test/org/apache/lucene/misc/index/TestBPReorderingMergePolicy.java:248-249 ('does not corrupt its content'); lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:175, 354, 413 (upstream tests call reorderIndexDirectory and main on successful runs only)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:102 (DEFAULT_COMMIT_ON_CLOSE = true); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:102-107 (a CREATE writer replaces the old index only when it commits), 1363-1369 (close() commits when commitOnClose), 3084-3086 (addIndexes transactional per call)lucene/CHANGES.txt:164 (GITHUB#14097 in the 11.0.0 section)
- The finding in 30 seconds
lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:97, :578-593, :596-605, :642-646, :653-682, :684-697lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:102 (DEFAULT_COMMIT_ON_CLOSE = true)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:103-107 (a CREATE writer replaces the old index only when it commits), :1344-1346 and :1363-1369 (close() commits), :3084-3086 (addIndexes transactional per call)
- Who is affected
lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:97 (budget), :642-646 (8 bytes per vector), :653-682 (main options)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3228-3236, :3288-3293 (per-segment created-major and field-info checks in addIndexes); lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:313-354 (testIndexReorderDense: forceMerge(1), then main)
- Why it looks unintended
lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:648-651 (main javadoc)lucene/misc/src/java/org/apache/lucene/misc/index/AbstractBPReorderer.java:63-67 (setRAMBudgetMB javadoc)lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:34-39lucene/misc/src/test/org/apache/lucene/misc/index/TestBPReorderingMergePolicy.java:248-251lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:175, :353-354, :412-413lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:103-107, :3084-3086lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:58 (OpenMode.CREATE javadoc)lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:4645, :4652, :4856; lucene/misc/src/java/org/apache/lucene/misc/index/IndexSplitter.java:44; lucene/core/src/java/org/apache/lucene/index/IndexUpgrader.java:166-196
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1363-1369, :3275, :3395lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1341-1353 (close() javadoc), :2566-2575 (rollback() javadoc)
- History
lucene/CHANGES.txt:164 (11.0.0 section)
- TestBpVectorReorderFailureKeepsIndex.java (lucene/misc, one test):
repro/LFV-1022/TestBpVectorReorderFailureKeepsIndex.java(48 lines)
A three-panel picture: the run (the index, the command and the exception), what happens inside reorderIndexDirectory, and the executed outcomes on main, expected vs actual, with the fix's results. Every value on screen was executed with the stock jars on main 645d4fe14a20 or checked against the code there.
assets/LFV-1022/bug.png(500 KB)
![Final frame of the diagram, three panels. Panel 1, the run with the stock command line and default options: $IDX has the commit segments_1 with segments _0 (1,000 docs) and _1 (600,000 docs); the command is java -Xms8m -cp lucene-core.jar:lucene-misc.jar org.apache.lucene.misc.index.BpVectorReorderer $IDX vec; totalMemory() is 32 MB at start, so the budget is 3.2 MB; it fails with NotEnoughRAMException: At least 5.0MB of RAM are required ..., but current RAM budget is 3.2MB (exit code 1). Panel 2, inside reorderIndexDirectory: condensed code with OpenMode.CREATE, the IndexWriter in try-with-resources and one addIndexes per segment, and try-with-resources calling writer.close(); _0 (1,000 x 8 B) fits the budget and is added as _2, not yet committed; _1 (600,000 x 8 B, about 4.6 MB, more than 3.2 MB) makes computeDocMap throw; close() commits by default: segments_2 = [_2], and segments_1 is deleted. Panel 3, executed on main, expected vs actual: $IDX after the failed run 601,000 docs vs 1,000 docs; its last commit segments_1 (_0, _1) vs segments_2 (_2); the first segment fails: all kept vs 0 docs; segments of 4, 4, 4 and 40 docs with the last failing: 52 docs vs 12 docs. Fix sketch: on any failure rollback() and rethrow, close() only after the last segment; with it the same run keeps 601,000 documents, the 1-document test passes (-ea and -da) and the upstream reorder tests pass (9 of 9). Footnote: the first-segment case was executed with the 1-document test and through main() on indexes written by 9.12.3 or 10.5.2 or with a soft-deletes or parent field; unreleased: on main and branch_11x (11.0.0-SNAPSHOT) since #14097 (2025-01-07), in no 9.x or 10.x release.](../assets/LFV-1022/bug.png)