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.
- With a
ReindexingEnrichedDirectoryTaxonomyWriter, index a document withAuthor/Alice, then commit the taxonomy and the main index. The taxonomy has 3 ordinals: root 0,Author1,Author/Alice2. - Index a document with
Author/Bob.FacetsConfig.buildgets ordinal 3 for it and stores 3 in the main-index document; the writer'sgetSize()is now 4. Nothing is committed yet. - Call
reindexWithNewOrdinalDatato 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". - Instead it reads the categories from the last commit, deletes the whole taxonomy, re-adds root,
AuthorandAlice, and commits:getSize()drops to 3 andAuthor/Bobis gone. The next new category,Author/Carol, gets ordinal 3. - 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 throwsArrayIndexOutOfBoundsException; on a never-committed taxonomy the call throwsIndexNotFoundException.
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.
- 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.buildasks 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'sgetSize()is the number of ordinals. A label, once added, is never deleted. - Ordinal data.
ReindexingEnrichedDirectoryTaxonomyWriteradds fields of your choice to each label's taxonomy document. To change them you callreindexWithNewOrdinalData(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.
reindexWithNewOrdinalData(ReindexingEnrichedDirectoryTaxonomyWriter.java:87-104) callsrecordPathsInOrder(getDirectory())(:91,:94), which opensnew DirectoryTaxonomyReader(d)(:63): the last commit only (DirectoryTaxonomyReader.java:128-129,:228-230).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).- Only the recorded labels are re-added, in ordinal order (
:100-102), andcommit()(:103) persists the shorter taxonomy. - 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 pastgetSize()and faceting throwsArrayIndexOutOfBoundsException(seen with commit-point readers and with a near-real-time pair, in the devil's advocate's and the diagram probes). - On a taxonomy that was never committed there is no commit to open:
:63throwsIndexNotFoundExceptionbefore anything is deleted.
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.experimentalclass (no special configuration otherwise) who callreindexWithNewOrdinalDatawhile 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.
ReindexingEnrichedDirectoryTaxonomyWriter.reindexWithNewOrdinalData drops a category added after the last taxonomy commit, although its javadoc promises 'The ordinals themselves will be preserved'.
/*
* 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));
}
}
}
}
# 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.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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. ThedeleteAlljavadoc (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, sincereplaceTaxonomyexists 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
deleteAllwarnings: "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.
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.
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 beforedeleteAll()(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; withTestOrdinalDataandTestDirectoryTaxonomyWriter: 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).
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.
"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.
Document that categories added since the last commit are dropped.
Trade-offs
No code risk, but nothing stops the silent damage. Not built.
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.
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.tlakeeps the writer's taxonomy, its last commit and main-index documents that store ordinals; the reindex is one atomic step (the method issynchronized) that snapshots either the commit (as coded) or the writer's view (the fix). Its invariantOrdinalsPreserved: 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; the JIRA archive; dev@, java-user@, issues@, commits@; downstream projects; the web | 2026-10-26 | two query sets of a scripted prior-report search, 80 queries, 0 errors | no report; the introducing #12337 / #12336 and the follow-up #13166 |
| GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr, Lucene.NET) | 2026-10-26 | 48 GraphQL issue and PR searches, 2 code searches | no 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 PRs | 2026-10-26 | changed 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 PRs | 2026-10-27 | re-check by class and method name | only #12337 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test fails 3 of 4 (gradle and standalone, assertions on and off); passes 4 of 4 with the fix |
| 10.5.2 | affected | this 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) | affected | history sweep: the finder's test fails 2 of 2 and the reproducer's 3 of 4 (control passes) on each |
| 9.10.0 | first release | this doc's test fails 3 of 4, assertions on and off; the first release with the class |
| 9.9.2 and older | no API | the class does not exist; the repro does not compile |
upstream main a95c754ee00, branch_10x, branch_10_6 | read | line 63 unchanged; not executed |
fix (scratch worktree at a9779ef3c193) | not affected | 4 of 4; facet module 310 tests, 0 failures |
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.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 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, high | Read 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 weak | Root 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 intended | Built 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 none | Re-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 | keep | Fresh 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 validated | New 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 fixed | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 510 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,505 words
- 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 mainea57360912ed, 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,TestDirectoryTaxonomyWriterand the whole facet module (310 tests), assertions on and off, plus the module's format, ecjLint and forbiddenApis checks.
- 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-235lucene/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-104lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :893-898, :918lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java :31-34, :36-45, :52-53, :82-93; TaxonomyReader.java :33-35lucene/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), :388lucene/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)
- TestReindexDropsUncommittedCategories.java (JUnit test):
repro/LFV-481/src/org/apache/lucene/facet/taxonomy/directory/TestReindexDropsUncommittedCategories.java(40 lines)
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.
assets/LFV-481/bug.png(510 KB)
