All findings · Lucene finding · LFV-977 · reindex-ordinal-data-not-failure-atomic

ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData leaves a half-rebuilt taxonomy when the appender throws, and close() commits it

If the ordinal-data appender throws during reindexWithNewOrdinalData, the taxonomy has already been deleted and only partly re-added, and the writer's usual close() commits that. In the repro 5 categories become 3, and the ordinals 3 and 4 that main-index documents store point nowhere; every release since 9.10.0 is affected.

Summary

Priority
Low, rank 318Needs the opt-in experimental reindexing taxonomy writer, an exception from the caller's own appender, and close() instead of rollback().
Impact
data corruption
Since
9.10.0 (2024), the first release with the classintroduced 2024-02-08 · about 2 years
Affected
every release from 9.10.0 to 10.6.0, and main
Status
documented
Found by
an executed failure-injection probe, then a bounded-exhaustive single-fault enumeration

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

The finding in 30 seconds

A taxonomy gives each facet label a number, its ordinal, and main-index documents store those numbers. This writer also keeps extra data per label; to change it, reindexWithNewOrdinalData deletes the taxonomy and re-adds every label in the same order, so the numbers stay. If the appender throws part-way, nothing undoes the delete.

Diagram of reindexWithNewOrdinalData failing part-way. Left: the last commit before the call, ordinals 0 to 4 for (root), d, d/a, d/b, d/c. Right: what close() commits, ordinals 0 to 2 for (root), d, d/a, with slots 3 and 4 marked gone. Expected, per the javadoc's ordinal promise: committed size 5, getOrdinal(d/b) = 3, getOrdinal(d/c) = 4, counts a=1 b=1 c=1. Actual after close(): size 3, getOrdinal(d/b) = -1 and getOrdinal(d/c) = -1; main-index documents still store 3 and 4, so FastTaxonomyFacetCounts throws ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3. Writer kept in use instead: new categories d/w and d/z get ordinals 3 and 4, counts a=1 w=2 z=2 instead of a=1 b=1 c=1 w=1 z=1, with no error. Notes: an appender that reads label.components[label.length - 1] throws on the root, which it sees only during a reindex, and close() then commits size 0; rollback() right after the exception keeps the last commit.
The committed taxonomy before and after: 5 categories, the appender throws on d/b, and close() commits 3, while the main index still stores ordinals 3 and 4. Select the still to open it at full size. Generated with AI, checked against the code.
  1. A taxonomy holds 5 categories: root 0, d 1, d/a 2, d/b 3, d/c 4; main-index documents store 2, 3 and 4. You call reindexWithNewOrdinalData(appender) inside try-with-resources to refresh the per-category data.
  2. The appender throws for d/b (say, no data for it). You expect the exception and an unchanged taxonomy: the javadoc promises the ordinals are preserved, so the main index needs no update.
  3. Instead the method has already deleted everything and re-added only root, d and d/a. Try-with-resources calls close(), which commits: the taxonomy now has 3 categories, and d/b and d/c resolve to -1.
  4. Facet counting over the unchanged main index throws ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3. If the writer is kept in use, new categories d/w and d/z get ordinals 3 and 4, and counts come out a=1 w=2 z=2 instead of a=1 b=1 c=1 w=1 z=1, with no error.
  5. It happens by accident too: an appender that reads the label's last component works in normal indexing but throws on the root category, which reaches it only during a reindex. close() then commits an empty taxonomy.

Who is affected

Applications that use the opt-in, experimental ReindexingEnrichedDirectoryTaxonomyWriter (since 9.10.0) and whose appender throws during a reindex, or adds a field IndexWriter rejects, and that then close the writer as usual (try-with-resources, finally, IOUtils.close). Deterministic and single-threaded, default codec. The damage is committed; rollback() right after the exception avoids it.

Mechanism, terms and numbers870 words

ReindexingEnrichedDirectoryTaxonomyWriter (facet module, @lucene.experimental, since 9.10.0) stores extra fields in the categories' taxonomy documents; to change them, the class javadoc says it is required to call reindexWithNewOrdinalData(appender), which promises "The ordinals themselves will be preserved, so the caller does not need to update references to them in the main index". The method records every category of the last commit, calls deleteAll() (which drops every taxonomy document from the writer, pending until the next commit, and resets the ordinal counter to 0), re-adds the categories one by one through the new appender, and commits only at the end, with no exception handling. If the appender throws for one category, the writer is left open with only the categories before it, and close(), which commits, makes that permanent. In the repro the committed taxonomy goes from 5 categories to 3 and d/b and d/c resolve to -1, while main-index documents still store their ordinals 3 and 4: facet counting throws ArrayIndexOutOfBoundsException, and a writer kept in use hands 3 and 4 to the next new categories, so documents are silently counted under the wrong labels. rollback() right after the exception keeps the last commit. Every release from 9.10.0 to 10.6.0 and main is affected; committing first and rolling back when the rebuild fails fixes it.

Terms

  • Taxonomy and ordinal. Taxonomy facets keep each label, such as d/b, once in a side index; its document number there is the label's ordinal. FacetsConfig.build asks the taxonomy writer for it (addCategory, FacetsConfig.java:383) and stores it in the main-index document as a doc-values field (:412). Ordinals start at 0 (the root) and grow by one per new label.
  • Ordinal data and the appender. ReindexingEnrichedDirectoryTaxonomyWriter lets you add fields to each label's taxonomy document through an appender, a BiConsumer<FacetLabel, Document>. To change those fields you call reindexWithNewOrdinalData(appender).
  • close() commits. DirectoryTaxonomyWriter.close() "commits whatever changes made to it"; rollback() discards them and closes the writer.

The bug (main 645d4fe14a20)

  1. reindexWithNewOrdinalData (ReindexingEnrichedDirectoryTaxonomyWriter.java:87-104) installs the new appender (:90), records all labels in ordinal order from the last commit (:94), then calls deleteAll() (:97). deleteAll() (DirectoryTaxonomyWriter.java:902-919) drops every taxonomy document from the writer (:903; IndexWriter.deleteAll "will not be visible until a commit() has been called", IndexWriter.java:2731-2733), resets the ordinal counter to 0 (:907) and clears the cache (:912).
  2. The loop (:100-102) re-adds each label with addCategory. For each one, addCategoryDocument calls the appender (enrichOrdinalDocument, DirectoryTaxonomyWriter.java:466; ReindexingEnrichedDirectoryTaxonomyWriter.java:50-54) before indexWriter.addDocument (:468) and the counter increment (:469).
  3. commit() is the last statement (:103), and there is no try. When the appender throws on d/b, it has been called for root, d, d/a and d/b; the writer holds 3 labels, its getSize() is 3, and it is still open. The last commit on disk is still intact; the damage is pending in the writer.
  4. close() calls commit() before closing (DirectoryTaxonomyWriter.java:288-294), so try-with-resources, a finally or IOUtils.close(...) persists the 3-label taxonomy. Main-index documents keep 3 and 4: lookups give -1 and counting throws. A writer kept open continues from counter 3, so the next new labels get 3 and 4 and those documents are counted under them.
  5. The root trap. The base constructor adds the root (DirectoryTaxonomyWriter.java:181-185) inside super(d), before the subclass stores the appender (ReindexingEnrichedDirectoryTaxonomyWriter.java:44-45), so normal indexing never shows the root to the appender. A reindex re-adds it first, as a label of length 0. An appender that reads label.components[label.length - 1] then throws ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 0, nothing is re-added, and close() commits size 0. Lucene's own test appender has this shape and guards it (TestOrdinalData.java:65-67).
  6. Not I/O faults. In a bounded-exhaustive enumeration, each of the 269 directory calls of a successful reindex was failed in turn, and all are safe: 7 fail the reads that record the labels, before deleteAll(); 226 fail while the new segment is written, after which the writer refuses further use and the last commit survives; the last 36 come at the end of the final commit, after every label had been re-added, so the ordinals are intact whichever commit is on disk. All 5 appender faults break the ordinals. A field the appender adds that IndexWriter rejects (a term over 32766 bytes) has the same effect as an appender exception: close() commits size 3.

The numbers

Taxonomy root 0, d 1, d/a 2, d/b 3, d/c 4, committed; main-index documents for a, b, c store 2, 3, 4; counts a=1 b=1 c=1. The appender throws on d/b. After try-with-resources close(): committed size=3, d/b=-1, d/c=-1; FastTaxonomyFacetCounts throws Index 3 out of bounds for length 3. Kept open instead: addCategory(d/w) = 3, addCategory(d/z) = 4; with one w and one z document the counts are a=1 w=2 z=2 (expected a=1 b=1 c=1 w=1 z=1). rollback() instead of close(): size 5, counts unchanged. Same on main with assertions on and off and on 10.5.2 (assertions on).

Exposure and likelihood211 words
  • Who is exposed. Users of this opt-in, @lucene.experimental class. Its javadoc makes reindexWithNewOrdinalData the required way to update the per-category data (ReindexingEnrichedDirectoryTaxonomyWriter.java:31-34), so every data refresh goes through it. Typical throwing appenders: a lookup that finds no data for a label, a validation error, or the root trap. A read-only code search found only copies of Lucene and one port as users of the class.
  • Trigger. An exception from the appender, or a non-aborting rejection of a field it added (Lucene's immense-term check), during the reindex; then close() or continued use instead of rollback(). Deterministic and single-threaded, with the default codec and IndexWriterConfig; one exception is enough (executed with 5 and 7 categories; nothing on the path depends on the size).
  • Effect. Durable: the truncated taxonomy is committed and survives reopen. Main-index documents keep ordinals that resolve to nothing (facet counting throws ArrayIndexOutOfBoundsException) or, after continued use, to other labels (silently wrong counts). Repair needs the taxonomy from a backup or a re-index of both indexes (inferred, not run).
  • Mitigating. The caller sees the exception first, and rollback() right after it keeps the last commit. Workaround until a fix: commit the taxonomy right before the call and call rollback(), not close(), if it throws.

Minimal repro

The test shows that ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData deletes the taxonomy before re-adding it, so when the ordinal-data appender throws part-way, a following close() commits a half-rebuilt taxonomy and a main-index ordinal loses its label.

TestReindexOrdinalDataFailureAtomicity.java (JUnit test)50 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.facet.taxonomy.directory;

import org.apache.lucene.facet.taxonomy.FacetLabel;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestReindexOrdinalDataFailureAtomicity extends LuceneTestCase {

  public void testFailedReindexThenCloseKeepsOrdinals() throws Exception {
    FacetLabel label = new FacetLabel("d", "a");
    try (Directory dir = newDirectory()) {
      ReindexingEnrichedDirectoryTaxonomyWriter writer =
          new ReindexingEnrichedDirectoryTaxonomyWriter(dir, null);
      int ordinal = writer.addCategory(label);
      writer.commit();
      expectThrows(
          IllegalStateException.class,
          () -> {
            try (writer) {
              writer.reindexWithNewOrdinalData(
                  (path, doc) -> {
                    if (path.equals(label)) {
                      throw new IllegalStateException("appender failure");
                    }
                  });
            }
          });
      try (DirectoryTaxonomyReader reader = new DirectoryTaxonomyReader(dir)) {
        // expected: d/a keeps its ordinal after the failed reindex and close(); actual: -1 (close() committed the half-rebuilt taxonomy)
        assertEquals(ordinal, reader.getOrdinal(label));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-977/, 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/facet/build/libs/lucene-facet-$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.facet.taxonomy.directory.TestReindexOrdinalDataFailureAtomicity
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected: after the failed reindex and close(), d/a still has ordinal 2 (method javadoc: "The ordinals themselves will be preserved"); actual: getOrdinal(d/a) = -1 (expected:<2> but was:<-1>).

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<2> but was:<-1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.facet.taxonomy.directory.TestReindexOrdinalDataFailureAtomicity.testFailedReindexThenCloseKeepsOrdinals(TestReindexOrdinalDataFailureAtomicity.java:46)

Why it looks unintended

  • The javadoc: "The ordinals themselves will be preserved, so the caller does not need to update references to them in the main index."
  • The method itself deletes everything and commits at the end, with no try; nothing says a failure requires rollback().
  • Lucene states what a failed addDocument or addIndexes leaves; this method does not, and close() commits the damage.
The full argument333 words
  • Contract. ReindexingEnrichedDirectoryTaxonomyWriter.java:82-86: "Delete the existing taxonomy index and recreate it using new ordinal data. The ordinals themselves will be preserved, so the caller does not need to update references to them in the main index." The class javadoc (:31-34) says that to update the custom data "it is required to" call this method. The deleteAll javadoc (DirectoryTaxonomyWriter.java:893-898) names it as the example of regenerating the taxonomy "taking care that ordinals are indexed in the same order as before". TaxonomyWriter.addCategory returns the existing ordinal of a category already present (TaxonomyWriter.java:53), yet after the failure a lost category's ordinal goes to a new one.
  • Lucene's own conventions. IndexWriter.addDocument: "if an Exception is hit (for example disk full) then the index will be consistent, but this document may not have been added" (IndexWriter.java:1465-1466). IndexWriter.addIndexes: "This method is transactional in how Exceptions are handled ... either no indexes will have been added or they all will have been" (:3084-3086). The addDocument promise covers one document, not a sequence of calls like this method's; the point is that Lucene states what a failure leaves behind, and this method does not.
  • Tests. The only upstream test of the method, TestOrdinalData.testReindex (:169-181), covers the success path. Its appender guards the root (:65-67) and its tearDown closes the writer with IOUtils.close(taxoWriter) (:121), the idiom that commits the damage. No test covers a failing appender.
  • History. The class came with #12337 "Index arbitrary fields in taxonomy docs" (stefanvodita, for #12336, merged 2024-02-08). Its review covered the API shape, updatable doc values, the deleteAll rename and its javadoc warning, and @lucene.experimental; nobody discussed a failure inside the re-add loop, rollback() or what close() commits afterwards. The 2023-05 dev@ design thread does not either.
  • Legal trigger. Public API only; the appender is the caller's BiConsumer and no javadoc restricts it. The root trap and Lucene's immense-term check fire without the caller throwing on purpose.
The devil's-advocate check272 words

Strongest case (a cross-model refute review, a cross-model intended-lens review that rated it strong but not decisive, and the devil's-advocate check): "the ordinals will be preserved" is a postcondition of a normal return, and the method says it will "Delete the existing taxonomy index"; the caller's own callback threw; rollback() is documented (DirectoryTaxonomyWriter.java:855-865; IndexWriter.deleteAll "can be rolled back using rollback()", IndexWriter.java:2731-2733) and works here, while close() is documented to commit (DirectoryTaxonomyWriter.java:283-286); a maintainer (s1monw, 2019, on open #9738 / LUCENE-8692) said IndexWriter failures from deleteAll(), addIndexes() and commit paths "might cause documents go missing etc. but we treated them not as fatal or tragic events since a user could always call rollback()"; and the class is @lucene.experimental.

Why it fails: nothing documents, for this method, a pending delete after a failure or a rollback() requirement; the method performs the destructive deleteAll() and the final commit() itself, so it presents as one self-contained operation whose stated purpose is exactly what breaks. #9738 concerns IndexWriter's low-level failures, is still open, and in it the same maintainer calls that handling inconsistent; for a non-aborting per-document failure, the kind that triggers this bug, Lucene documents consistency without a rollback, though only for that one document. The usual close idioms commit the damage, and DirectoryTaxonomyWriter has no way to close without committing. @lucene.experimental was asked for in #12337 "so we reserve the right to break them" (the API), not to accept silent data loss. Verdict: a defect under the documented API. Residual risk: maintainers may prefer a javadoc note ("call rollback() after a failed reindex") to a code fix.

Suggested fix

Recommended Commit first, then roll back when the rebuild fails (validated)

Commit first, then delete, re-add and commit inside try; on any failure in that block, rollback() to that commit and rethrow (+24/-6 in one file). The new test passes, and so do the facet module's 307 tests and its static checks. After such a failure the writer is closed, so a caller's own rollback() then throws AlreadyClosedException.

ensureOpen();
commit(); // first, so a rollback restores exactly this taxonomy
this.ordinalDataAppender = ordinalDataAppender;
List<FacetLabel> ordinalToPath = recordPathsInOrder(getDirectory());
try {
  deleteAll();
  for (FacetLabel categoryPath : ordinalToPath) {
    addCategory(categoryPath);
  }
  commit();
} catch (Throwable t) {
  try { rollback(); } catch (Throwable t2) { t.addSuppressed(t2); }
  throw t;
}
All options and trade-offs4 options ยท 763 words

Option A was built and validated with gradle in a scratch worktree at 645d4fe14a20, the whole facet module included; options B, C and D were not built.

Commit first, then roll back when the rebuild fails (validated) (recommended)

One file, ReindexingEnrichedDirectoryTaxonomyWriter.java (+24/-6): commit() before recording the paths, so a rollback restores exactly the taxonomy the call started from; deleteAll(), the re-add loop and the final commit() inside try; on any Throwable from that block, rollback() (which keeps the last commit and closes the writer), rollback failures added as suppressed, and rethrow. A javadoc paragraph says so. The snippet is condensed (comments and the javadoc left out; getDirectory() inlined).

ensureOpen();
commit(); // first, so a rollback restores exactly this taxonomy
this.ordinalDataAppender = ordinalDataAppender;
List<FacetLabel> ordinalToPath = recordPathsInOrder(getDirectory());
try {
  deleteAll();
  for (FacetLabel categoryPath : ordinalToPath) {
    addCategory(categoryPath);
  }
  commit();
} catch (Throwable t) {
  try { rollback(); } catch (Throwable t2) { t.addSuppressed(t2); }
  throw t;
}

Trade-offs

  • An extra commit at the start when changes are pending. It is allowed by the taxonomy-first commit order (the method commits at the end anyway), and it also makes the recorded paths include categories added since the last commit, which removes the trigger of a related success-path bug in the same method.
  • Scope. The try starts at deleteAll(). A failure before it (the first commit(), or reading the labels) is rethrown without a rollback: nothing has been deleted, but after a failed read the writer stays open with the new appender installed. If rollback() itself throws (not tested), it is added as suppressed, and the taxonomy writer may then not be marked closed, because DirectoryTaxonomyWriter.rollback() closes only after indexWriter.rollback() returns (DirectoryTaxonomyWriter.java:863-864); nothing is committed on that path.
  • The writer is closed after a failure in the try. Further calls throw AlreadyClosedException and close() is a no-op (DirectoryTaxonomyWriter.java:289), but a caller's own rollback() also throws AlreadyClosedException, because DirectoryTaxonomyWriter.rollback() starts with ensureOpen() (:862), unlike IndexWriter.rollback() (IndexWriter.java:2577). Executed: the root-trap test's rollback() control fails that way with the fix. A PR could also let rollback() return on an already-closed writer (not built).
  • Keeping the writer open after a failure is not possible this way, since the internal IndexWriter's rollback closes it.
  • Taxonomies already damaged are not repaired. The file is identical from 9.10.0 to main, so the patch backports unchanged.

What the checks validated

  • Scratch worktree at 645d4fe14a20, gradle, JDK 25: the new test fails on stock and passes with the fix, assertions on and off.
  • The whole facet module with the fix: 40 suites, 307 tests (306 upstream + the new one), 0 failures, 8 skipped, including TestOrdinalData and TestDirectoryTaxonomyWriter; :lucene:facet:check -x test (formatting, ecjLint, forbiddenApis and the other static checks) passes.
  • Standalone runner with the patched class shadowed: the new test passes with assertions on and off, and the root-trap test now commits size 4. Earlier, the same logic passed the finder's 2 tests, 44 upstream facet tests, and the failure-point enumeration with 0 violations.
  • After the fact checks the javadoc paragraph was reworded (code unchanged) and re-validated the same way: formatting and static checks, the new test with assertions on and off, the whole facet module (40 suites, 307 tests, 0 failures, 7 skipped with another seed) and two standalone shadow runs.

Run the appender on every label before deleting anything (not built)

Call the appender on a throwaway Document for each recorded label before deleteAll().

Trade-offs

An appender exception (the repro, the root trap) then leaves the taxonomy and the writer untouched and open, but the appender runs twice per label, and failures that are not the appender's, such as IndexWriter rejecting an appended field, still need option A's rollback.

Javadoc only (not built)

Document that if the method throws once it has started deleting the taxonomy (any failure from deleteAll() on, such as an appender exception), the taxonomy has been deleted in the writer and only partly re-added, and the caller must call rollback(), not close(); a failure before that (ensureOpen(), reading the labels) leaves the taxonomy untouched.

Trade-offs

No code risk, and rollback() already restores the last commit, but try-with-resources and IOUtils.close still commit the damage, and the method's promise stays unconditional.

Build the new taxonomy in a separate directory and swap it in (not built)

For example with replaceTaxonomy; the writer stays open and untouched until the swap.

Trade-offs

Needs a temporary directory and a second write of the whole taxonomy, and replaceTaxonomy has the same failure-atomicity problem.

Downstream note

The appender is the application's own code (the labels it receives can come from indexed FacetField values). It would matter for security only if a product let less-trusted parties supply the appender's data or start a reindex; query filtering is not redirected, because drill-down builds its terms from paths in the main index, not from ordinals.

How it was found

An exception-safety probe that made the appender throw during a reindex found it on main. It was reduced to a public-API test, and a bounded-exhaustive single-fault enumeration then showed that all 269 I/O faults of a reindex are safe, while all 5 appender faults break the ordinals.

The method in full196 words

Method: an executed failure-injection probe (non-formal tier: the first signal was an executed test, not a model). A planning probe for exception safety in the taxonomy writers made the appender fail on its third call during reindexWithNewOrdinalData; after close() the committed taxonomy had 2 categories and d/a, d/b, d/c resolved to -1, on main, 10.5.2, 9.12.3 and a 10.6 release candidate (2026-10-28).

  • The hunt reduced it to a public-API JUnit on the stock jars (2 of 2 fail with assertions on and off; 20 of 20 over 10 iterations).
  • It then ran a bounded-exhaustive single-fault enumeration on the stock jar: each of the 269 directory calls of a successful reindex failed in turn (all safe: 7 fail before deleteAll(), 226 make the writer refuse further use with the last commit intact, and 36 come at the end of the final commit, after every label was re-added), and the appender failed at each of its 5 calls (all break the ordinals), each under two follow-up probes (close() only; one more category, then close()). With the fix sketch: 0 violations.
  • The same unit found the sibling pattern in DirectoryTaxonomyWriter.replaceTaxonomy, 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 mailing lists, downstream projects and all 414 open PRs. Only the introducing PR #12337 mentions the class, and its review never discussed a failing reindex.

Searches4 searches

Verdict new. The nearest items introduced the method or touch the same files elsewhere; 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 archive; the web2026-10-28 and 2026-10-29three query sets of a scripted prior-report search, 147 queries, 0 errorsno report; top hit #12337, the introducing PR
GitHub (apache/lucene, downstream projects, Lucene.NET, all of GitHub)2026-10-2921 manual searches (class and method names, ordinalDataAppender, taxonomy rollback, deleteAll, failure atomic) and a code search for users of the classonly #12337; reindexWithNewOrdinalData finds nothing
Read and not the same bug2026-10-29full threads#12337 and #12336 and the 2023-05 dev@ design thread (no failure discussion); #13166, #12553; #5949 / LUCENE-4884, #4653 / LUCENE-3579 (background on deleteAll and rollback); #6112, #6203, #16625 (term overlap)
Open apache/lucene PRs and recent activity2026-10-29changed files of all 414 open PRs; every PR since 2026-10-26; the commits API for both files on main, branch_10x and branch_10_6#16847 (lines 68-70 only), #12862 (an import), #13951 (a cast): not the reindex sequence; no later commit to the class

History

Introduced with the API by #12337 (merged 2024-02-08) and first released in 9.10.0; the file has not changed since. Fails on every release tested from 9.10.0 to 10.6.0 and on main; 9.9.2 has no such class. Not fixed upstream.

Versions tested8 rows

Introduced by #12337 "Index arbitrary fields in taxonomy docs" (stefanvodita; issue #12336): f339e24e8ed6 on main and the branch_9x cherry-pick 2d713d904d01, both 2024-02-08; CHANGES 9.10.0, New Features, "GITHUB#12336: Index additional data per facet label in the taxonomy." It added the class with the delete / re-add / commit sequence and no failure handling, the enrichOrdinalDocument hook and DirectoryTaxonomyWriter.deleteAll(); git log --all --follow lists only this commit and its cherry-pick for the class. The commit in close() is much older (2011/2013) and only makes the damage durable. The class is byte-identical in all 19 releases from 9.10.0 to 10.5.2 and on main; upstream main 645d4fe14a20, branch_10x and branch_10_6 still have it.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis doc's test fails (gradle and standalone, assertions on and off, 10 of 10 iterations); passes with the fix
10.5.2 affectedthis doc's test fails; the history sweep's main() and JUnit fail, assertions on and off
9.11.0, 9.12.3, 10.0.0 affectedhistory sweep: the finder's main() fails; the JUnit fails 2 of 2 on 9.12.3 and 10.0.0; the no-throw control passes where run
9.11.1, 9.12.0-9.12.2, 10.1.0-10.5.1 readnot executed; the class and deleteAll() are identical
9.10.0 first releasethis doc's test fails; the history sweep fails, assertions on and off
9.9.2 and older no APIthe class does not exist; the repro does not compile
branch_10_6 4ae946e5a9b affectedhistory sweep: the finder's main() fails
fix (scratch worktree at 645d4fe14a20) not affectedthe test passes; facet module 307 tests, 0 failures

Independent checks

Confirmed by an independent reproducer with its own test, a devil's advocate, an adjudication and an audit. Reviews from another model family (OpenAI GPT-5.6, reading only) agreed on the root cause and raised the intent question ("call rollback()"). This doc's test and fix were re-run on main.

Checks and verdicts347 words

Every executing check wrote or ran its own test on the stock jars, and the rollback() and no-throw controls pass. The cross-model reviews could read code only, so all execution evidence comes from the hunt's own AI tools and test harness.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn JUnit (7 categories, the appender throws on color/green): 4 of 4 fail on main (assertions on and off), 10.5.2 and 9.12.3: size 3 after close(), addCategory(color/blue) returns 3, counting throws Index 3 out of bounds for length 3, and the immense-term variant; its controls pass. Re-run on 645d4fe14a20: the same.
cross-model review, refute lens not real, medium (it could not run code, so it voted not real on procedure; it also argued that rollback() is the documented remedy and rated that case strong)Read the code only, so it voted not real on procedure; root cause agrees, inputs in contract. Its substantive point, rated strong: rollback() is documented to discard changes after a failure and close() to commit, so the caller should call rollback(), not close(). A separate run re-ran the finder's JUnit: 2 of 2 fail.
cross-model review, intended lens uncertain, mediumRoot cause agrees; rates the intended-behaviour argument strong but not decisive. A separate run re-ran the finder's JUnit: 2 of 2 fail.
devil's advocate real, mediumBuilt the strongest works-as-designed case and refuted it; executed the root trap (size 0) and its rollback() control (passes).
adjudication confirmed, mediumRe-ran the independent reproduction's tests (4 of 4 fail, assertions on and off) and its controls (pass).
audit keepRe-ran the finder's JUnit with a fresh seed (2 of 2 fail); a committer would accept it as a real, niche robustness bug.
this doc reproduces; fix validatedThe new test fails on 645d4fe14a20 (gradle and standalone, assertions on and off), 10.5.2 and 9.10.0; the finder's, the independent reproduction's and the intended-behaviour check's tests fail the same way there; fix A passes the test and the facet module.

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

Finding record

LFV-977, snapshot of 2026-11-06

Registry title
ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData is not failure-atomic: when the ordinal-data appender throws part-way, the taxonomy has already been deleted and only partly re-added, and a following close() commits it, so main-index ordinals lose their labels or are handed to other categories
Area
facet / taxonomy (ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData)
Priority
Low, rank 318: Needs the opt-in experimental reindexing taxonomy writer, an exception from the caller's own appender, and close() instead of rollback().
Impact class
data corruption
Impact note
Only applications that use the opt-in, @lucene.experimental ReindexingEnrichedDirectoryTaxonomyWriter (since 9.10.0) are hit, when their ordinal-data appender throws during reindexWithNewOrdinalData (or adds a field that IndexWriter rejects, such as a term over 32766 bytes) and they then close() the writer, as try-with-resources does, or keep using it instead of calling rollback(): the truncated taxonomy is committed durably, deterministically, single-threaded and at any size, with the default codec and IndexWriterConfig, and main-index documents then lose their facet labels, are counted under other categories, or make facet counting throw ArrayIndexOutOfBoundsException; the caller does see the exception first, and rollback() right after it keeps the last commit.
Summary
You call reindexWithNewOrdinalData on a ReindexingEnrichedDirectoryTaxonomyWriter with a new ordinal-data appender, the appender throws for one category (in the repro an IllegalStateException on d/b; an appender that reads the last label component throws on the root category, which reaches the appender only during a reindex), and the application then closes the writer as usual. You expect the method's promise 'The ordinals themselves will be preserved, so the caller does not need to update references to them in the main index' to hold, or at least the last committed taxonomy to survive the failed call. Instead the method has already deleted the whole taxonomy and re-added only the categories before the failing one, and close() commits that: the taxonomy shrinks from 5 to 3 categories, d/b and d/c resolve to -1 while main-index documents still carry their ordinals 3 and 4, and if the writer is kept in use new categories take those ordinals, so facet counts a=2 b=1 w=1 z=1 come out as w=3 z=2 with no error (other layouts make FastTaxonomyFacetCounts throw ArrayIndexOutOfBoundsException).
Introduced
f339e24e8ed64bf64ef4bb05111d252ecdacde1e (main) and its branch_9x cherry-pick 2d713d904d011767f5e98bbc763f84a0b4fa508f (the commit that shipped in 9.10.0), apache/lucene#12337 'Index arbitrary fields in taxonomy docs' (author stefanvodita; issue #12336; CHANGES 9.10.0 New Features: 'GITHUB#12336: Index additional data per facet label in the taxonomy.') [read: it added the class with reindexWithNewOrdinalData (record paths, deleteAll(), re-add loop, commit() only at the end, no failure handling), the enrichOrdinalDocument hook and the package-private DirectoryTaxonomyWriter.deleteAll(); git log --follow lists only this commit (and the cherry-pick) for the file, and git blame -w gives it every line of 82-104] [exec: 9.9.2 cannot compile the repro, the control or the JUnit (class absent); 9.10.0 fails]. The commit in DirectoryTaxonomyWriter.close() is older (javadoc from LUCENE-3079, 2011; the explicit commit() line from LUCENE-4972, e2c54ff680b8, 2013) and only persists the damage (2024-02-08 (f339e24e8ed6 authored and committed 2024-02-08T11:02:12Z, squash merge; cherry-pick 2d713d904d committed 2024-02-08T11:13:36Z; PR opened 2023-05-27))
First affected version
9.10.0 [exec, -ea and -da], the first release with the API; 9.9.2 has no ReindexingEnrichedDirectoryTaxonomyWriter [exec: compile error, only that symbol missing]; the main-line commit first ships in 10.0.0 [read: git tag --contains]
Affected versions
9.10.0, 9.11.0, 9.11.1, 9.12.0, 9.12.1, 9.12.2, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (branch_10_6 head 4ae946e5a9b) and main a9779ef3c193 [exec: the failing main() fails on 9.10.0 (-ea, -da), 9.11.0, 9.12.3, 10.0.0, 10.5.2 (-ea, -da), branch_10_6 and main (-ea, -da) with the same line, and its no-throw control passes on each; the finder's JUnit fails 2 of 2 on 9.10.0 (-ea, -da), 9.12.3, 10.0.0, 10.5.2 (-ea, -da) and main (-ea, -da); the independent reproduction's JUnit fails 4 of 4 on main, 10.5.2 and 9.12.3]. ReindexingEnrichedDirectoryTaxonomyWriter.java is byte-identical (sha1 prefix cb9719702c76) in all 19 release tags, the history head 7cecf795236 and main a9779ef3c193, and the deleteAll() body is identical in all 19 tags [read]; the releases not executed are inferred from that. Not fixed upstream: the last commit touching the file on main (head 645d4fe14a20, 2026-10-28), branch_10x and branch_10_6 is still f339e24e8ed6 [read: commits API, 2026-10-29]. Not a regression. Executed only on aarch64 Linux with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), 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, read-only; the prior-report record, plus the finder's scripted prior-report search at 2026-10-28, the independent reproduction's, the intended-behaviour check's and the adjudication's searches, and a recheck): three scripted prior-report searches (147 queries over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 0 errors), 21 manual GitHub searches (apache/lucene, downstream projects, Lucene.NET and all of GitHub: 'ReindexingEnrichedDirectoryTaxonomyWriter' finds only #12337, 'reindexWithNewOrdinalData' finds nothing), 12 manual list searches, a GitHub code search for users of the class, the changed files of all 414 open apache/lucene PRs, every PR created since 2026-10-26 (none touches facet/), and the upstream commits API for the class and DirectoryTaxonomyWriter.java on main, branch_10x and branch_10_6, compared by author date (last change f339e24e8ed6, authored 2024-02-08). Related, not this bug: #12337 (the introducing PR; its review covered the API shape, updatable doc values, the deleteAll rename and its javadoc warning, never a failure inside the re-add loop or what close() commits after one), #12336 and the 2023-05 dev@ design thread, #13166 (demo request), #12553 (faceting gaps), #5949 / LUCENE-4884 (deleteAll leaves files), #4653 / LUCENE-3579 (rollback closes the writer), #6112 / LUCENE-5048 and #6203 / LUCENE-5139 (term overlap only), #16625 (IndexWriter rollback); open PRs #16847 (recordPathsInOrder lines 68-70 only), #12862 (an import) and #13951 (a cast in DirectoryTaxonomyWriter) touch the files but not the delete / re-add / commit sequence. Tracking issue #16937 does not mention it and has no comments
Lucene commit tested
main 645d4fe14a20 (2026-10-28) with gradle in a scratch worktree and with the standalone JUnit runner, assertions on and off (10 of 10 iterations fail); the 10.5.2 and 9.10.0 release jars (this doc's test). The hunt, the independent checks and the registry re-runs ran on main a9779ef3c193, where lucene/facet and IndexWriter.java are identical; the history sweep (27 runs) covered 9.9.2, 9.10.0, 9.11.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main. Fix A was validated in the scratch worktree at 645d4fe14a20: the new test with assertions on and off, the whole facet module (307 tests) and the module's static checks.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:31-36 (at a9779ef3c193) (class javadoc: updating the custom data 'is required' to go through reindexWithNewOrdinalData; @lucene.experimental at :36), :48-54 (enrichOrdinalDocument calls the appender), :82-86 (method javadoc: 'Delete the existing taxonomy index and recreate it using new ordinal data. The ordinals themselves will be preserved, so the caller does not need to update references to them in the main index.'), :87-104 (ensureOpen :89, new appender set :90, recordPathsInOrder :94, deleteAll :97, re-add loop :100-102, commit :103; no try/catch)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:283-294 (at a9779ef3c193) (close() javadoc 'commits whatever changes made to it'; commit() at :290), :855-865 (rollback() rolls back and closes the writer), :893-919 (deleteAll; nextID.set(0) :907, cache.clear() :912, ++indexEpoch :918), :440-446 (enrichOrdinalDocument hook, no precondition), :466 (appender call) and :468 (indexWriter.addDocument after it)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:53 (at a9779ef3c193) ('If the category was already present in the taxonomy, its existing ordinal is returned'); lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestOrdinalData.java:65 (the test appender guards the root, facetLabel.length == 0), :121 (tearDown closes the writer with IOUtils.close), :170 (the only reindex call, success path)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:44-45, :50-54, :87-104 (installs the appender :90, recordPathsInOrder :94, deleteAll :97, loop :100-102, commit :103; no try/catch)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:181-185 (root added in the constructor), :283-294 (close() javadoc and commit), :452-469 (enrichOrdinalDocument :466, addDocument :468, nextID :469), :902-919 (deleteAll)
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java:383, :412
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestOrdinalData.java:65-67 (root guard in the test appender)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:31-36 (class javadoc, @lucene.experimental)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:31-34, :82-86
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:283-286, :855-865, :893-898
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:53
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1465-1466 (addDocument), :2731-2733 (deleteAll can be rolled back), :3084-3086 (addIndexes transactional)
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestOrdinalData.java:65-67, :121, :169-181
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:289, :855-865 (rollback: ensureOpen at :862); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2576-2577
History
  • lucene/CHANGES.txt:2984

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

Repro files

About the diagrams

A before-and-after picture of the committed taxonomy, with the expected and actual results and the two other outcomes (continued use, the root trap). Every value comes from a probe executed on main 645d4fe14a20 with assertions on and off, on 10.5.2, and with the fix. The diagram uses the probe's example, one main-index document per category, so continued use gives a=1 w=2 z=2; the finder's JUnit (and the registry summary) uses another layout, where a=2 b=1 w=1 z=1 comes out as w=3 z=2.

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.