All findings · Lucene finding · LFV-481 · reindex-ordinal-data-drops-uncommitted-categories

ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData drops categories added since the last commit and reuses their ordinals

reindexWithNewOrdinalData promises to keep every ordinal but rebuilds the taxonomy from its last commit, so a category added since then (Author/Bob, ordinal 3) disappears and the next new category (Author/Carol) takes ordinal 3. After both commits the Bob document is counted as Carol: Alice=1 Bob=-1 Carol=2.

Summary

Priority
Medium, rank 156Deterministic, durable taxonomy damage where reused ordinals miscount documents under other categories, but only in an opt-in experimental class with no known downstream user, the same niche-tool profile as LFV-408.
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.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
TLA+ model checking of the taxonomy and main-index pair (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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 and rebuilds the taxonomy to change it, promising the same numbers. The bug: it rebuilds from the last commit, so labels added since then vanish and their numbers go to new labels.

Diagram of the main index and the taxonomy around reindexWithNewOrdinalData, in the finder's flow: the taxonomy is committed with root 0, Author 1 and Author/Alice 2; a document with Author/Bob gets ordinal 3 before the next commit; the reindex reads only the last commit, deletes the taxonomy and re-adds ordinals 0 to 2, so getSize() drops from 4 to 3; Author/Carol is then added and gets ordinal 3, so Bob's document is counted as Carol: Alice=1 Carol=1 instead of Alice=1 Bob=1. A side table adds the drill-down on Author/Bob (labelled Carol=1), getSize() and addCategory(Author/Carol) (3 instead of 4), the ArrayIndexOutOfBoundsException when counting before Carol is added, and the IndexNotFoundException on a never-committed taxonomy.
The main index and the taxonomy, step by step: Bob's document holds ordinal 3, the rebuild from the last commit drops Bob, and Carol takes ordinal 3. Select the still to open it at full size. Generated with AI, checked against the code.
  1. With a ReindexingEnrichedDirectoryTaxonomyWriter, index a document with Author/Alice, then commit the taxonomy and the main index. The taxonomy has 3 ordinals: root 0, Author 1, Author/Alice 2.
  2. Index a document with Author/Bob. FacetsConfig.build gets ordinal 3 for it and stores 3 in the main-index document; the writer's getSize() is now 4. Nothing is committed yet.
  3. Call reindexWithNewOrdinalData to update the per-category data. You expect all 4 ordinals to stay: its javadoc says the caller "does not need to update references to them in the main index".
  4. Instead it reads the categories from the last commit, deletes the whole taxonomy, re-adds root, Author and Alice, and commits: getSize() drops to 3 and Author/Bob is gone. The next new category, Author/Carol, gets ordinal 3.
  5. After both commits, fresh readers count Alice=1 Bob=-1 Carol=2: the Bob document is counted as Carol, in both committed indexes, with no error. With no new category, faceting fresh readers throws ArrayIndexOutOfBoundsException; on a never-committed taxonomy the call throws IndexNotFoundException.

Who is affected

Applications that use the opt-in, experimental ReindexingEnrichedDirectoryTaxonomyWriter and call reindexWithNewOrdinalData while the writer holds a category added since its last commit, the normal state of a writer that keeps indexing. One uncommitted category is enough; the outcome does not depend on timing or the test seed, and the damage is committed. No use was found in Elasticsearch, OpenSearch or Solr.

Mechanism, terms and numbers605 words

ReindexingEnrichedDirectoryTaxonomyWriter (facet module, @lucene.experimental, since 9.10.0) stores extra data in each category's taxonomy document, and to change that data the class javadoc says it is required to call reindexWithNewOrdinalData, whose javadoc 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 the categories to re-add through new DirectoryTaxonomyReader(Directory), which sees only the last commit, then deleteAll() empties the taxonomy and resets the ordinal counter, only the committed categories are re-added, and the method commits. A category added since the last commit, whose ordinal FacetsConfig.build already stored in main-index documents, is gone, and the next new category gets its ordinal. In the repro Author/Bob (ordinal 3) is dropped, the writer's getSize() goes from 4 to 3, Author/Carol takes ordinal 3, and after both commits the counts are Alice=1 Bob=-1 Carol=2 instead of 1, 1, 1. On a taxonomy that was never committed the call throws IndexNotFoundException. Every release from 9.10.0 to 10.6.0 (10.6.0 by reading) and main is affected; reading the writer's own near-real-time view, new DirectoryTaxonomyReader(this), fixes it.

Terms

  • Taxonomy and ordinal. Taxonomy facets keep each label, such as Author/Bob, once in a side index; its document number there is the label's ordinal. FacetsConfig.build asks the taxonomy writer for the ordinal (addCategory) and stores it in the main-index document as a doc-values field; facet counts are kept per ordinal. Ordinals start at 0 (the root) and grow by one per new label; the writer's getSize() is the number of ordinals. A label, once added, is never deleted.
  • Ordinal data. ReindexingEnrichedDirectoryTaxonomyWriter adds fields of your choice to each label's taxonomy document. To change them you call reindexWithNewOrdinalData(appender): it deletes the taxonomy, re-adds every label in ordinal order with the new data, and commits, so the main index needs no update.
  • Commit point vs. the writer's view. new DirectoryTaxonomyReader(Directory) opens the last commit; new DirectoryTaxonomyReader(taxoWriter) opens the writer's near-real-time view, which includes labels added since the last commit.

The bug

  1. reindexWithNewOrdinalData (ReindexingEnrichedDirectoryTaxonomyWriter.java:87-104) calls recordPathsInOrder(getDirectory()) (:91, :94), which opens new DirectoryTaxonomyReader(d) (:63): the last commit only (DirectoryTaxonomyReader.java:128-129, :228-230).
  2. deleteAll() (:97; DirectoryTaxonomyWriter.java:902-919) deletes every taxonomy document, including the labels added since that commit, and resets the ordinal counter to 0 (:907).
  3. Only the recorded labels are re-added, in ordinal order (:100-102), and commit() (:103) persists the shorter taxonomy.
  4. The next new label gets the next free ordinal (DirectoryTaxonomyWriter.java:469), which is the first dropped one. Main-index documents that hold it are counted and labelled under the new label. Until a new label takes it, the ordinal lies past getSize() and faceting throws ArrayIndexOutOfBoundsException (seen with commit-point readers and with a near-real-time pair, in the devil's advocate's and the diagram probes).
  5. On a taxonomy that was never committed there is no commit to open: :63 throws IndexNotFoundException before anything is deleted.

The numbers

This doc's test (default FacetsConfig; the test framework's randomized directory and main-index settings): commit after Author/Alice (ordinals 0-2); a document with Author/Bob gets ordinal 3 (getSize() 4); reindexWithNewOrdinalData; getSize() is now 3 and getOrdinal(Author/Bob) is -1; a document with Author/Carol gets ordinal 3; after both commits FastTaxonomyFacetCounts gives Alice=1 Bob=-1 Carol=2. With a commit right before the call, all ordinals are kept. Same on main, 10.5.2 and 9.10.0, assertions on and off, and for every seed tried; the finder's and the independent reproducer's tests and the diagram probe, with a plain new IndexWriterConfig(...), fail the same way.

Exposure and likelihood207 words
  • Who is exposed. Users of this opt-in, @lucene.experimental class (no special configuration otherwise) who call reindexWithNewOrdinalData while the writer holds a category added since its last taxonomy commit: a writer that keeps indexing between commits (for example a scheduled refresh of per-category data), or a batch build that adds its last categories and reindexes without committing first. A read-only code search found no use of the class in Elasticsearch, OpenSearch or Solr; custom applications that embed the facet module are the ones exposed.
  • Trigger. Deterministic and single-threaded; one uncommitted category is enough; no special values, timing or scale.
  • Effect. Silent and persisted: the method's own commit writes a taxonomy without those categories, main-index documents keep the old ordinals, and the next new categories take them, so those documents are counted and labelled under other categories, also from freshly opened readers. Before a new category takes the ordinal, faceting over the affected documents throws ArrayIndexOutOfBoundsException (seen with commit-point readers and with a near-real-time pair). A later correct reindex keeps the wrong mapping, so repair needs the affected main-index documents re-indexed (inferred, not run).
  • Workaround. Commit the taxonomy, with indexing paused, right before the call. Pausing matters: other threads can add categories between a caller's commit() and the call.

Minimal repro

ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData drops a category added after the last taxonomy commit, although its javadoc promises 'The ordinals themselves will be preserved'.

TestReindexDropsUncommittedCategories.java (JUnit test)40 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.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestReindexDropsUncommittedCategories extends LuceneTestCase {

  public void testUncommittedCategoryOrdinalIsNotPreserved() throws Exception {
    FacetLabel bob = new FacetLabel("Author", "Bob");
    try (Directory taxoDir = new ByteBuffersDirectory();
        ReindexingEnrichedDirectoryTaxonomyWriter taxoWriter =
            new ReindexingEnrichedDirectoryTaxonomyWriter(taxoDir, null)) {
      taxoWriter.commit();
      int bobOrd = taxoWriter.addCategory(bob);
      taxoWriter.reindexWithNewOrdinalData((label, doc) -> {});
      try (DirectoryTaxonomyReader taxoReader = new DirectoryTaxonomyReader(taxoDir)) {
        // uncommitted Author/Bob must keep its ordinal: expected 2, main gives -1 (dropped)
        assertEquals(bobOrd, taxoReader.getOrdinal(bob));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-481/, 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.TestReindexDropsUncommittedCategories
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected Author/Bob to keep ordinal 2 in the taxonomy the call commits; main gives getOrdinal(Author/Bob) = -1.

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

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.TestReindexDropsUncommittedCategories.testUncommittedCategoryOrdinalIsNotPreserved(TestReindexDropsUncommittedCategories.java:36)

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." No commit precondition.
  • The writer's own getSize() drops from 4 to 3, though a category, once added, "can never be deleted".
  • The only upstream test commits right before the call: this case is untested.
The full argument245 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".
  • The writer contradicts itself. TaxonomyWriter.getSize: "the taxonomy contains ordinals 0 through getSize()-1" (TaxonomyWriter.java:82-93); the same writer reports 4, then 3. TaxonomyReader: "once a category is added, it can never be deleted" (TaxonomyReader.java:33-35). These general rules weigh less than the method's own promise, since replaceTaxonomy exists and the method itself starts with "Delete the existing taxonomy index and recreate it"; the strongest contract is that the ordinals are preserved.
  • Tests. The only upstream test, TestOrdinalData.testReindex (:169-181), runs after its setup committed the taxonomy (:110) and checks the new data, not ordinals.
  • History. In the review of #12337, which added the class, mikemccand asked for deleteAll warnings: "you must carefully regenerate the ordinals in the right order?" That puts the burden on this method. The commit-point reader was there from the first revision with the class and was never discussed.
  • Legal trigger. Public API only, in the documented taxonomy-first commit order (TaxonomyWriter.java:36-45).
The devil's-advocate check317 words

Strongest case (the devil's advocate; the cross-model intended-lens reviewer called it weak, the later cross-model fact check moderate, since several design signals point to a quiesced rebuild at a commit boundary): TaxonomyWriter says "What the writer writes is only available to (new) readers after the writer's commit() is called", so "the existing taxonomy index" could mean the committed one; the only test commits first; the design thread before #12336 listed the option "New ordinal data can only come in when a new taxonomy gets built"; the class is @lucene.experimental ("so we reserve the right to break them"); deleteAll() bumps the taxonomy epoch, so an NRT SearcherTaxonomyManager treats a reindex like replaceTaxonomy; and in LUCENE-4886 (2013, Not A Problem) a maintainer told a user to commit before opening a Directory-based taxonomy reader.

Why it fails: the writer's own getSize() shrinks and its addCategory reuses ordinal 3 for another label, so from the writer's side the existing taxonomy includes Bob; the visibility rule describes readers, not a precondition on a synchronized writer method that runs on the live writer and commits by itself; no javadoc, CHANGES entry or test states a precondition; the design option is not the API that shipped, and even a batch build loses its last categories if it reindexes before committing; experimental permits API changes, not silently losing categories that committed documents reference; the epoch behaviour is separate and unchanged by the fix; in LUCENE-4886 the user's own code opened the reader, here Lucene's method does, and the fix follows that thread's other advice (open the reader on the writer). This is a claim about the documented contract; whether the author had only quiesced, just-committed rebuilds in mind is not known, and a maintainer may answer by documenting that (option C). Verdict: a defect under the documented API. Expect the reply "commit first"; answer with the javadoc promise and the getSize() 4-to-3 test.

Suggested fix

Recommended Read the writer's own near-real-time view (validated)

Record the categories to re-add from the writer's own near-real-time view, new DirectoryTaxonomyReader(this), instead of the last commit (a one-line change, +4/-5 with the signature cleanup). The new test passes 4 of 4, and the facet module's 310 tests pass with assertions on and off.

private List<FacetLabel> recordPathsInOrder() throws IOException {
  List<FacetLabel> paths = new ArrayList<>();

  // Read this writer's own (NRT) view, which includes categories added since the last commit.
  DirectoryTaxonomyReader taxoReader = new DirectoryTaxonomyReader(this);
  ...

// reindexWithNewOrdinalData: drop "Directory d = getDirectory();"
List<FacetLabel> ordinalToPath = recordPathsInOrder();
All options and trade-offs4 options · 596 words

Option A was built and validated with gradle in a scratch worktree, the whole facet module included; option B was run on the new test and the two closest suites; options C and D were not built.

Read the writer's own near-real-time view (validated) (recommended)

One file, ReindexingEnrichedDirectoryTaxonomyWriter.java: recordPathsInOrder opens new DirectoryTaxonomyReader(this) instead of new DirectoryTaxonomyReader(d), and reindexWithNewOrdinalData no longer passes the Directory. It is the finder's fix and the idiom a maintainer recommended in LUCENE-4886.

private List<FacetLabel> recordPathsInOrder() throws IOException {
  List<FacetLabel> paths = new ArrayList<>();

  // Read this writer's own (NRT) view, which includes categories added since the last commit.
  DirectoryTaxonomyReader taxoReader = new DirectoryTaxonomyReader(this);
  ...

// reindexWithNewOrdinalData: drop "Directory d = getDirectory();"
List<FacetLabel> ordinalToPath = recordPathsInOrder();

Trade-offs

  • Safe because the taxonomy index has no deletions apart from deleteAll() and its merge policy keeps doc ids ordered, so the near-real-time reader's doc ids are the ordinals, as the writer's own lookups already assume; on the success path the reader is closed before deleteAll() (there is no try/finally, as on stock); the method holds the writer's monitor, so no new category can be added while it runs.
  • Cost: opening the near-real-time reader forces a full flush of the taxonomy's internal writer: still-buffered documents are written to segment files, with no commit point and no commit fsync. Not benchmarked; the method rewrites the whole taxonomy anyway.
  • Taxonomies already damaged are not repaired; the affected main-index documents need re-indexing.
  • The file is identical from 9.10.0 to main, so the patch applies to every branch; open PR #16847 edits lines 68-70 of the same method and may need a trivial rebase.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25: the new test fails 3 of 4 on stock and passes 4 of 4 with the fix, assertions on and off; with TestOrdinalData and TestDirectoryTaxonomyWriter: 22 tests, 0 failures, 1 skipped (testHugeLabel).
  • The whole facet module: 40 suites, 310 tests (306 upstream + 4 new), 0 failures, with assertions on (7 skipped) and off (8 skipped); :lucene:facet:check -x test (google-java-format, ecjLint, forbiddenApis) passes.
  • With the patched class shadowed in the standalone runner: the new test, the finder's 2 tests, the independent reproducer's 4 tests, the devil's advocate's 3 probes and the security probe pass. In the finder's TLA+ model, recording the writer's view is clean for 3 labels and 4 documents (1,562 distinct states).

Commit first, inside the method (validated on the test and the closest suites)

Call commit() before recordPathsInOrder(d) (+3 lines).

Trade-offs

Correct because the method is synchronized like addCategory's add path. Costs an extra commit() call: when changes are pending (the case that triggers the bug) it writes and syncs an intermediate commit point, and with nothing pending it commits nothing (IndexWriter.commit() returns -1); the uncommitted categories become durable before the caller's next main-index commit, which the taxonomy-first rule allows and the method's final commit does anyway.

What the checks validated

Gradle on main: the new test passes 4 of 4; with TestOrdinalData and TestDirectoryTaxonomyWriter, 22 tests pass (assertions on); format check passes. The whole module was not run.

Document a precondition and check it

"Commit the taxonomy, with indexing paused, before calling", and throw IllegalStateException when the writer holds uncommitted categories.

Trade-offs

Fails loudly instead of corrupting, but moves the burden to every caller and narrows an unconditional promise. A maintainer may prefer it, citing LUCENE-4886. Not built.

Javadoc only

Document that categories added since the last commit are dropped.

Trade-offs

No code risk, but nothing stops the silent damage. Not built.

Downstream note

In an application that shares one taxonomy across tenants, the reused ordinals put another tenant's category name on a tenant's own facet counts, while matching and drill-down stay within the tenant's filter.

How it was found

TLA+ model checking of the taxonomy and main-index pair. While mapping the model to the code, the finder noticed which snapshot the reindex reads; the model checker then gave a 4-state counterexample (add a category, index a document, reindex) that breaks ordinal preservation, and it was reduced to a JUnit on the stock jars.

The method in full166 words

Method: TLA+ model checking (TLC) (semi-formal tier: a hypothesis formed while modelling, then witnessed by the tool), in a hunt unit on the taxonomy and main-index pair.

  • The model TaxoReindex.tla keeps the writer's taxonomy, its last commit and main-index documents that store ordinals; the reindex is one atomic step (the method is synchronized) that snapshots either the commit (as coded) or the writer's view (the fix). Its invariant OrdinalsPreserved: every main-index document still resolves to the label it was indexed with.
  • With the commit snapshot, TLC reports a 4-state trace, AddCat -> AddDoc -> Reindex, that violates OrdinalsPreserved (19 distinct states). With the writer's view the model is clean for 3 labels and 4 documents (1,562 distinct states), and a probe invariant confirms that reindexes after documents are explored.
  • The trace was reduced to a public-API JUnit on the stock jars, then checked by an independent reproducer, a devil's advocate, reviewers from another model family (OpenAI GPT-5.6, reading only), an adjudication and an audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, re-checked 2026-10-27): GitHub issues and PRs, the JIRA archive, the mailing lists, downstream projects and all open PRs. Closest: LUCENE-4886 (2013, Not A Problem), where a maintainer said a Directory-based taxonomy reader sees only commits: commit first, or open the reader on the writer.

Searches4 searches

Verdict new. The nearest items explain the mechanism or touch the same writer, but none reports this method dropping categories.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; the JIRA archive; dev@, java-user@, issues@, commits@; downstream projects; the web2026-10-26two query sets of a scripted prior-report search, 80 queries, 0 errorsno report; the introducing #12337 / #12336 and the follow-up #13166
GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr, Lucene.NET)2026-10-2648 GraphQL issue and PR searches, 2 code searchesno report; #5951 (LUCENE-4886) explains the mechanism for application code; #5698 (LUCENE-4633) and #6306 (LUCENE-5242) are older, fixed bugs in other code of the same writer
Open apache/lucene PRs2026-10-26changed files of all 408 open PRs#16847 edits lines 68-70 of the same method (not line 63); #12862 moves an import
apache/lucene issues and PRs2026-10-27re-check by class and method nameonly #12337

History

Introduced with the API by #12337 (merged 2024-02-08) and first released in 9.10.0; the file has not changed since. Every release from 9.10.0 to 10.5.2 and main fails the repro; 9.9.2 has no such class. Still unfixed on main, branch_10x and branch_10_6.

Versions tested7 rows

Introduced by #12337 "Index arbitrary fields in taxonomy docs" (by stefanvodita, approved by mikemccand, for #12336): f339e24e8ed6 on main and the branch_9x backport 2d713d904d01, both 2024-02-08; CHANGES 9.10.0, New Features, "GITHUB#12336: Index additional data per facet label in the taxonomy." It created the class, including the commit-point reader at line 63, and DirectoryTaxonomyWriter.deleteAll(); on main it is the only non-merge commit that touched the file (git blame gives it all 105 lines), and 2d713d904d01 is its branch_9x backport. The commit-point reader was there from the first PR revision with the class (1059645b84c, 2023-06-03). Upstream main (a95c754ee00, 2026-10-26), branch_10x and branch_10_6 still have it.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 3 of 4 (gradle and standalone, assertions on and off); passes 4 of 4 with the fix
10.5.2 affectedthis doc's test fails 3 of 4, assertions on and off; the finder's and the reproducer's tests fail
9.11.0 to 10.5.1 (17 releases) affectedhistory sweep: the finder's test fails 2 of 2 and the reproducer's 3 of 4 (control passes) on each
9.10.0 first releasethis doc's test fails 3 of 4, assertions on and off; the first release with the class
9.9.2 and older no APIthe class does not exist; the repro does not compile
upstream main a95c754ee00, branch_10x, branch_10_6 readline 63 unchanged; not executed
fix (scratch worktree at a9779ef3c193) not affected4 of 4; facet module 310 tests, 0 failures

Independent checks

Confirmed by an independent reproducer with its own test, a devil's advocate, a reviewer from another model family (OpenAI GPT-5.6, reading only), an adjudication and an audit; this doc's test and fix were re-run with gradle on main. A second cross-model run gave a procedural "not real" in its refute lens because it could not run Java.

Checks and verdicts428 words

Every executing check wrote or ran its own test on the stock jars; the commit-first controls pass everywhere. 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, other labels and flow: 3 of 4 fail on main (assertions on and off), 10.5.2, 9.12.3 and 9.10.0; the control passes; changing only line 63 makes all 4 pass.
cross-model review before the candidate real, highRead the code only; contract quoted, root cause agrees, fix addresses it, intended-behaviour argument weak.
cross-model review, refute lens not real, low (procedural)It could not run Java, and it voted not real on procedure only. 9 of its 11 reading evidence items supported real (one, the TaxonomyWriter note that readers see only commits, supported not real; one was neutral); its notes say the static evidence strongly favors a genuine defect, and it rated the intended-behaviour argument weak. A separate run re-ran the finder's JUnit: 2 of 2 fail.
cross-model review, intended lens real, medium; intended argument weakRoot cause agrees and the fix fixes it (by reading). A separate run re-ran the finder's JUnit: 2 of 2 fail.
devil's advocate real; high that it is not intendedBuilt and refuted the works-as-designed case; probes: getSize() 4 to 3 with no main index, the near-real-time ArrayIndexOutOfBoundsException; the control passes; all pass with the fix.
adjudication confirmed, high; duplicate of noneRe-ran the independent reproduction's JUnit on main: 3 of 4 fail; with the fix shadowed, 4 of 4 pass.
registry re-runs and audit keepFresh seeds on main: the finder's JUnit fails 2 of 2 with assertions on and off, the independent reproduction's fails 3 of 4 and passes with the fix; impact class upheld.
this doc's runs reproduced; fix validatedNew 4-test JUnit: 3 fail on main (gradle and standalone), 10.5.2 and 9.10.0, assertions on and off; the recommended fix passes the test and the facet module; the commit-first variant passes the test and the closest suites.
fact checks of this doc bug confirmed; wording fixedAn independent fact check re-ran the brief's gradle command on a9779ef3c193 (4 tests, 3 failed, assertions on and off), the fix with the whole facet module (310 tests pass) and the test from a foreign package; a cross-model fact check (OpenAI GPT-5.6, reading only) and a diagram check also passed the bug. Their wording and citation findings are fixed here.

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

Finding record

LFV-481, snapshot of 2026-11-06

Registry title
ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData rebuilds only the last committed taxonomy, so categories added since that commit are dropped and their ordinals are later reused for other categories
Area
facet / taxonomy (ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData)
Priority
Medium, rank 156: Deterministic, durable taxonomy damage where reused ordinals miscount documents under other categories, but only in an opt-in experimental class with no known downstream user, the same niche-tool profile as LFV-408.
Impact class
data corruption
Impact note
Only applications that use the opt-in, @lucene.experimental ReindexingEnrichedDirectoryTaxonomyWriter (no use was found in Elasticsearch, OpenSearch or Solr) and call reindexWithNewOrdinalData while the writer holds categories added since its last taxonomy commit are hit, deterministically, single-threaded and at any size (one uncommitted category suffices), with the default codec and IndexWriterConfig and in the documented taxonomy-first commit order: the method's own commit() persists a taxonomy without those categories and the next new categories take their ordinals, so main-index documents are counted and labelled under other categories, also from freshly opened readers (until a new category takes a dropped ordinal, faceting over the affected hits throws ArrayIndexOutOfBoundsException instead), and on a taxonomy that was never committed the call throws IndexNotFoundException; committing the taxonomy, with indexing paused, right before the call avoids it.
Summary
You index a document with the category Author/Bob through a ReindexingEnrichedDirectoryTaxonomyWriter after its last commit, then call reindexWithNewOrdinalData to refresh the per-category data, whose javadoc promises 'The ordinals themselves will be preserved, so the caller does not need to update references to them in the main index'. Instead the method rebuilds the taxonomy from a reader of the last commit, so Author/Bob disappears (the writer's getSize() drops from 4 to 3) and its ordinal 3 goes to the next new category, Author/Carol. After both indexes are committed, the main index's Bob document is counted as Carol (Alice=1 Carol=1 instead of Alice=1 Bob=1) with no error; on a taxonomy that was never committed the call throws IndexNotFoundException.
Introduced
f339e24e8ed64bf64ef4bb05111d252ecdacde1e (main) and its branch_9x backport 2d713d904d011767f5e98bbc763f84a0b4fa508f, GITHUB#12337 'Index arbitrary fields in taxonomy docs' (author stefanvodita, approved by mikemccand; addresses issue #12336; CHANGES 9.10.0 New Features: 'GITHUB#12336: Index additional data per facet label in the taxonomy.') [read: it created ReindexingEnrichedDirectoryTaxonomyWriter, including recordPathsInOrder's new DirectoryTaxonomyReader(Directory) at line 63, and DirectoryTaxonomyWriter.deleteAll(); git blame gives every line of the file to f339e24e8ed, it is the only commit that ever touched the file, the file is absent in the parent bff5ac0ed01, and the Directory-based path reader is present from the class's first PR revision 1059645b84c (2023-06-03)] [exec: 9.9.2 cannot compile the repro (class absent) and 9.10.0 fails; no commit-level build, because the parent cannot compile the repro] (2024-02-08 (f339e24e8ed authored and committed 11:02:12 +0000, merged 2024-02-08T11:02:12Z; backport 2d713d904d0 committed 11:13:36 +0000; PR opened 2023-05-27))
First affected version
9.10.0 [exec, -ea], the first release with the API; no earlier release has it [exec: 9.9.2 compile error, class absent; read: no facet jar from 4.0.0 to 9.9.2 contains the class]
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 and main ea57360912ed [exec: on every one the finder's JUnit fails 2 of 2 and the independent reproduction's 3 of 4 with -ea, the independent reproduction's commit-first control passing; the finder's also fails with -da on 9.12.3, 10.5.2 and main; the security probe fails on main, 10.5.2 and 9.10.0]. ReindexingEnrichedDirectoryTaxonomyWriter.java is byte-identical at 9.10.0, 9.11.1, 9.12.3, 10.0.0, 10.5.2 and main [read]. Not fixed upstream: line 63 still opens new DirectoryTaxonomyReader(d) on upstream main a9779ef3c193 and branch_10x (2026-10-26) [read]; open PR #16847 (long doc ids) edits lines 68-70 of the same method but not line 63 [read, 2026-10-26]. branch_10_6 was not read for this row; at the 10.6.0 release commit 4ae946e5a9b the file is identical to main ea57360912ed [read, 2026-10-30], so 10.6.0 (released 2026-10-29) is affected. 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, plus the finder's scripted prior-report search and the independent reproduction's and the intended-behaviour check's searches): two scripted prior-report search query sets (GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 80 queries, 0 errors), 48 manual GitHub GraphQL searches (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr, Lucene.NET), 2 GitHub code searches, and the changed files of all 408 open apache/lucene PRs (only #12862, an import move, lists the file; the registration check re-read the four PRs whose list was capped at 100 files: #365, #13949 and #16911 do not touch it, and #16847, long doc ids by jimczi, changes lines 68-70 of recordPathsInOrder but not line 63). Related, not this bug: #12337 (the introducing PR; its review covered the API shape, the reset-to-deleteAll rename, javadoc warnings and visibility, never categories added since the last commit), #12336 and the dev@ design thread before it, #13166 (open demo request), #5951 / LUCENE-4886 (2013, Not A Problem: a Directory-based taxonomy reader sees only commits, and mikemccand advised committing first or opening the reader on the writer, which is this bug's mechanism and the fix's idiom, but it concerned application code 11 years before this method existed), #5698 / LUCENE-4633 and #6306 / LUCENE-5242 (earlier replaceTaxonomy state bugs in the same writer, fixed 2012 and 2013), #5949 / LUCENE-4884 (files left after deleteAll), #6203 / LUCENE-5139 (AIOOBE from an application's reopen order).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) with gradle in a scratch worktree and with the standalone JUnit runner, assertions on and off; 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 ea57360912ed, whose affected files are byte-identical; the history sweep ran 45 times on 9.9.2, all 19 releases from 9.10.0 to 10.5.2, and main. The recommended fix was validated in the scratch worktree: the new test, TestOrdinalData, TestDirectoryTaxonomyWriter and the whole facet module (310 tests), assertions on and off, plus the module's format, ecjLint and forbiddenApis checks.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:31-36 (at ea57360912ed) (class javadoc: 'To update the custom data added to the docs, it is required to reindexWithNewOrdinalData', @lucene.experimental), :60-80 (recordPathsInOrder; :63 new DirectoryTaxonomyReader(d)), :82-86 (method javadoc: 'The ordinals themselves will be preserved, so the caller does not need to update references to them in the main index'), :87-104 (synchronized, ensureOpen :89, getDirectory :91, recordPathsInOrder :94, deleteAll :97, re-add :100-102, commit :103)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:893-919 (at ea57360912ed) (deleteAll javadoc names reindexWithNewOrdinalData as the example of regenerating ordinals 'in the same order as before'; :902 deleteAll, :907 nextID.set(0), :912 cache.clear(), :918 ++indexEpoch), :469 (nextID.getAndIncrement: the next new category takes the next free ordinal)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:128-131 (at ea57360912ed) (Directory constructor: DirectoryReader over the last commit), :146-149 (writer constructor: near-real-time view)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:31-34 (at ea57360912ed) ('What the writer writes is only available to (new) readers after the writer's commit() is called'), :38-42 (commit the taxonomy before the main index), :83-93 (getSize: the taxonomy contains ordinals 0 through getSize()-1); TaxonomyReader.java:34-35 and :53-55 (once a category is added, it can never be deleted)
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestOrdinalData.java:110 (at ea57360912ed) (setUp commits the taxonomy), :170 (testReindex calls reindexWithNewOrdinalData afterwards: the uncommitted case is untested); lucene/CHANGES.txt:2951 (GITHUB#12336, 9.10.0)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java :31-36, :60-80 (:63), :82-86, :87-104 (:89, :91, :94, :97, :100-102, :103)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :381-402, :452-482 (:468-469), :575-577, :893-919 (:903, :907, :912, :918)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java :128-129, :146-149, :228-235
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java :383-384 (addCategory, collect the ordinal), :412 (doc.add(new SortedNumericDocValuesField(indexFieldName, ord)))
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java :82-93; TaxonomyReader.java :33-35
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :388 (addCategory's add path takes the writer's monitor)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java :31-36, :82-86, :87-104
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :893-898, :918
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java :31-34, :36-45, :52-53, :82-93; TaxonomyReader.java :33-35
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestOrdinalData.java :110, :169-181
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :238-242 (merge policy keeps doc ids ordered), :252, :364-367 (the writer's NRT lookups map doc id to ordinal), :388
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :4203 (commit() with no pending changes returns -1); ReindexingEnrichedDirectoryTaxonomyWriter.java :63-78 (no try/finally); DirectoryTaxonomyWriter.java :385-388 (cache lookup outside the monitor)
History
  • lucene/CHANGES.txt :2937 (9.10.0), :2954 (New Features), :2967 (GITHUB#12336)
  • a Lucene clone (origin/main a95c754ee00, origin/branch_10x 1309534296e, origin/branch_10_6 2763db11b0a: line 63 unchanged, file identical to a9779ef3c193)

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

Repro files

About the diagrams

A step-by-step picture of the finder's flow, where Carol is added to the taxonomy without a document of its own (so the counts are Alice=1 Carol=1 rather than this doc's Alice=1 Bob=-1 Carol=2). Every value on screen comes from a diagram probe executed on main a9779ef3c193 with assertions on and off and with the fix.

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.