All findings · Lucene finding · LFV-1022 · bpvector-reorder-cli-commits-on-failure

BpVectorReorderer's in-place command-line reorder commits an empty or partial index over the original when reordering fails

If BpVectorReorderer's in-place command line fails part-way, for example with its documented NotEnoughRAMException, try-with-resources closes its CREATE-mode writer and close() commits: the index is replaced by an empty or partial one (601,000 documents became 1,000 in one run). Unreleased (main and branch_11x); rolling the writer back on failure fixes it.

Summary

Priority
Medium, rank 101A 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
data corruption
Since
#14097 (2025-01-07); main and branch_11xintroduced 2025-01-07 · about 1 year
Affected
main and branch_11x (11.0.0-SNAPSHOT); no release has the tool
Status
documented
Found by
code reading for failure atomicity and executed probes, then a bounded-exhaustive single-fault harness

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

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().

Three things to know first

  1. An index on disk is its last commit: a segments_N file that lists the segments. A writer opened with OpenMode.CREATE starts a new, empty index in the same directory; until it commits, readers still see the old one.
  2. IndexWriter.close() commits whatever the writer holds, because commitOnClose is true by default; rollback() discards it and keeps the last commit.
  3. After a commit, the default deletion policy keeps only that commit: the previous commit and the files only it used are deleted.
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.
The stock command line on an index with segments of 1,000 and 600,000 documents in a small-heap JVM: the second segment exceeds the 3.2 MB budget, 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.

The finding, step by step

  1. You run the reorder tool on a vector index, BpVectorReorderer <indexDir> <field>, to renumber its documents in place. It opens a writer in OpenMode.CREATE on the same directory and adds the segments back one at a time, reordered.
  2. One segment needs at least the tool's RAM budget, a tenth of the JVM's starting heap: with -Xms8m the 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 expect NotEnoughRAMException and an untouched index.
  3. 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.
  4. 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).
  5. The minimal test: 1 document, budget Double.MIN_VALUE. reorderIndexDirectory throws NotEnoughRAMException, and the index then has 0 documents. With rollback() on failure it keeps its 1 document.

Who is affected

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.

The bug (main 645d4fe14a20)

  1. 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 calls reorderIndexDirectory(dir, executor) on FSDirectory.open(...) (:679-681).
  2. The RAM budget is set in the constructor to a tenth of Runtime.getRuntime().totalMemory() (:97), the JVM's current heap, which early in main() is about the initial heap. computeValueMap throws NotEnoughRAMException when a segment's vectors, at 8 bytes each (:642-646), need at least that budget (>=, :596-605); computeDocMap (:578-593) runs once per segment.
  3. reorderIndexDirectory (:684-697) opens a reader on the directory, then try (IndexWriter writer = new IndexWriter(directory, iwc)) with OpenMode.CREATE and an otherwise default IndexWriterConfig, and calls writer.addIndexes(SortingCodecReader.wrap(leaf, computeDocMap(leaf, null, executor), null)) for each segment. Each addIndexes call is transactional on its own (IndexWriter.java:3084-3086), not the loop.
  4. When computeDocMap throws (NotEnoughRAMException; an AssertionError under -ea) or addIndexes throws (an IllegalArgumentException for a 9.x/10.x-created, soft-deletes or parent-field index; an IOException), try-with-resources calls close(). With commitOnClose (IndexWriterConfig.java:102) close() writes, waits for merges and commits (IndexWriter.java:1363-1369).
  5. The new commit, segments_2, lists only the segments added before the failure; the default KeepOnlyLastCommitDeletionPolicy deletes the original commit and its files. After the 9.12.3, 10.5.2, soft-update and parent-field runs the directory holds only segments_2 and write.lock.
  6. 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 NotEnoughRAMException and the IllegalArgumentExceptions are.

The numbers (stock jars on main)

triggerbeforeafter the failed run
minimal test: budget Double.MIN_VALUE1 document0
stock main(), default options, -Xms8m (budget 3.2 MB), segments of 1,000 and 600,000 vectors (the second needs 4.6 MB)601,0001,000
stock main(), default options, index written by the 9.12.3 or 10.5.2 jars100
stock main(), default options, soft deletes (one soft update) / parent field12 / 100 / 0
a later segment over budget, segments of 4, 4, 4 and 405212
one transient I/O fault at each of 225 directory calls (3 x 20 documents)60partial 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 run BpVectorReorderer's command line on an index, or call reorderIndexDirectory from the same package. Applications that use BPReorderingMergePolicy or computeDocMap with their own IndexWriter are 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 (InitialRAMPercentage 1.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 -Xms change 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 calling main()).
  • 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.

Minimal repro

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.

TestBpVectorReorderFailureKeepsIndex.java (lucene/misc, one test)48 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 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());
      }
    }
  }
}
Command
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>.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT), gradle and a standalone JUnit runner
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (pure Java logic; nothing platform-specific)
Command (environment)
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

Observed output

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

Why it looks unintended

  • The documented outcome of too little RAM is a report: "a NotEnoughRAMException will be thrown" (setRAMBudgetMB).
  • The sibling BPReorderingMergePolicy skips 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); testReorderDoesntHaveEnoughRAM uses the same Double.MIN_VALUE budget and "makes sure that reordering the index on merge does not corrupt its content" (TestBPReorderingMergePolicy.java:248-251).
  • IndexWriter semantics. 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-place IndexUpgrader also closes its writer in try-with-resources, but on the existing index (default CREATE_OR_APPEND), so a commit after a failure still holds every document (read, not executed). BpVectorReorderer carries no warning, no @lucene.experimental and no backup advice.
  • Tests and history. Upstream tests call reorderIndexDirectory (TestBpVectorReorderer.java:175) and main (: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".

Suggested fix

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.

Roll back on failure; close (commit) only after every segment was added (validated) (recommended)

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. Throwable is caught so an AssertionError (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: the IndexWriter constructor failing, rollback() itself failing, or close() 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:check with the fix: formatting (checkGoogleJavaFormat), ecjLint, forbiddenApis and the whole module's tests (24 suites, 317 tests, 0 failures, 9 skipped), including TestBpVectorReorderer (8 of 8) and TestBPReorderingMergePolicy (its two @Nightly tests 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).

setCommitOnClose(false) plus an explicit commit() after the loop (not built)

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

Write the reordered index to a new directory (not built)

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.

Javadoc only (not built)

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 note

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.

How it was found

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-place CREATE writer, default commitOnClose) 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 stock main() 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the JIRA archive2026-10-29two 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 web2026-10-298 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-2920 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 bug2026-10-29every 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)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis 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) readthe 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) affectedthe 10-document version fails (10 -> 0)
02a09f6ab12 (#14097) introducedthe 10-document version fails with -ea and -da
72d93de50c2 (parent) no APIthe repro does not compile: the classes do not exist
9.12.3, 10.1.0, 10.2.0, 10.5.2 no APIthe repro does not compile
branch_10x (decommissioned 2026-10-29), branch_10_6 (10.6.0, released), branch_10_5 readneither file exists (git ls-tree)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn 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, highAn 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, highSame 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, highBuilt 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, highRe-ran the repro (10 -> 0, -ea and -da) and the intended-behaviour check's main() test (4 -> 0); no-noise gate passed.
audit keepRe-ran the repro with fresh seeds: fails with -ea and -da. Committer view: real and worth fixing before 11.0.
this doc reproduces; fix validatedThe 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 correctRe-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.

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

Finding record

LFV-1022, snapshot of 2026-11-06

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 commit 02a09f6ab12, the next commit to the file b7c7fe064ce, and (compile error, the tool does not exist) its parent 72d93de50c2 and 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 stock main() on main. The fix was validated in the scratch worktree at 645d4fe14a20: the test with assertions on and off, the whole lucene/misc test suite and its static checks. Re-checked 2026-10-30 by reading: upstream main 716d6f216f5 and the 11.0 release branch branch_11x 603af870375 have the same BpVectorReorderer.java, AbstractBPReorderer.java, IndexWriter.java and IndexWriterConfig.java as 645d4fe14a20 (the repro was not run on branch_11x).

Lucene and upstream sources

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-697
  • 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: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-39
  • lucene/misc/src/test/org/apache/lucene/misc/index/TestBPReorderingMergePolicy.java:248-251
  • lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:175, :353-354, :412-413
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:103-107, :3084-3086
  • lucene/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, :3395
  • lucene/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)

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

Repro files

About the diagrams

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.

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.