All findings · Lucene finding · LFV-520 · taxonomy-writer-rejected-ordinal-document-shifts-ordinals

DirectoryTaxonomyWriter gives later categories wrong ordinals after IndexWriter rejects one enriched ordinal document

If IndexWriter rejects one enriched taxonomy document (here a 32,767-byte description), it still takes a doc ID but DirectoryTaxonomyWriter's ordinal counter does not move, so later categories get ordinals one too low until a merge reclaims that document. Two color/y documents are then counted as color: bad (2), x (1).

Summary

Priority
Low, rank 317Needs the opt-in experimental ordinal-enrichment hook plus a rejected enrichment field and continued use of the writer.
Impact
data corruption
Since
9.10.0 (2024), with the taxonomy enrichment hook (#12337)introduced 2024-02-08 · about 2 years
Affected
every release from 9.10.0 to 10.5.2, and main
Status
documented
Found by
TLA+ model checking of the taxonomy writer's add path (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

In a taxonomy, each facet label is one document and its ordinal is that document's doc ID. DirectoryTaxonomyWriter advances its ordinal counter only after IndexWriter.addDocument returns. A document that IndexWriter rejects still uses up a doc ID, so after one rejection the counter is one behind for every later label.

Diagram of the taxonomy's category documents and DirectoryTaxonomyWriter's ordinal counter: color/x is doc 2; the enriched document for color/bad is rejected by IndexWriter but stays as deleted doc 3 while the counter stays at 3; color/y becomes doc 4 but is handed ordinal 3, so its two documents are counted as color: bad (2), x (1).
The taxonomy's doc IDs next to the writer's ordinal counter, step by step: the rejected color/bad document keeps doc ID 3, the counter stays at 3, and every later label is one off. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You add your own fields to the taxonomy's category documents with ReindexingEnrichedDirectoryTaxonomyWriter: each category gets a description term. A category's ordinal is its document's doc ID: color is 1, color/x is 2.
  2. One document's category, color/bad, has a 32,767-byte description, one byte over IndexWriter's term limit. FacetsConfig.build throws IllegalArgumentException; you skip that document and index two color/y documents and two size ones.
  3. You expect only color/bad to be missing: color: y (2), x (1).
  4. Instead IndexWriter kept the rejected document as deleted doc 3, but the writer's counter stayed at 3. color/y is doc 4 yet gets ordinal 3, which a reader resolves to color/bad: the counts say color: bad (2), x (1), and nothing throws.
  5. It sticks: the wrong ordinals are stored in your documents and committed, and a reopened writer contradicts the first one (in the writer-only test, category b gets 2, then 3). Other cases throw ArrayIndexOutOfBoundsException or CorruptIndexException.

Who is affected

Applications that add their own fields to taxonomy documents (ReindexingEnrichedDirectoryTaxonomyWriter or an enrichOrdinalDocument override: opt-in, experimental, 9.10.0 and later) where IndexWriter can reject a value, such as a term over 32,766 bytes or a vector of another dimension, and that keep the writer after the exception. Rare, but silent and persistent.

Mechanism, terms and numbers809 words

In a taxonomy index each facet label is one document, and its ordinal is that document's doc ID; the taxonomy must never contain deleted documents. Since 9.10.0, ReindexingEnrichedDirectoryTaxonomyWriter (or an enrichOrdinalDocument override) lets an application add its own fields to those documents. DirectoryTaxonomyWriter.addCategoryDocument calls the hook, then indexWriter.addDocument(d), and advances its ordinal counter only after that call returns. When IndexWriter rejects the document with a non-aborting exception, such as a term over 32,766 bytes or a vector of another dimension, it has already used up a doc ID and keeps the document as deleted. addCategory throws IllegalArgumentException, the application skips that category, and from then on, until a merge reclaims the deleted document, every new category gets an ordinal one below its doc ID: readers resolve it to the previous category (even the rejected label), main-index documents store it, and commit() persists it. In the repro two color/y documents are counted as color: bad (2), x (1) with no error, and a reopened writer returns 3 instead of 2 for a category that already exists. Other shapes throw ArrayIndexOutOfBoundsException or CorruptIndexException. The opt-in, experimental enrichment hook is the only demonstrated trigger; every release from 9.10.0 to 10.5.2, and main, is affected.

Terms

  • Taxonomy and ordinal. Taxonomy facets store each label, such as color/x, once in a side index, as one document; its doc ID is the label's ordinal. Main-index documents store ordinals, and facet counts are kept per ordinal. The writer's own notes say the taxonomy never has deleted documents: "internal docids of the configured index must not be altered. For that, categories are never deleted from the taxonomy index" (DirectoryTaxonomyWriter.java:227-229; also :364, :628-632).
  • Enrichment. Since 9.10.0, ReindexingEnrichedDirectoryTaxonomyWriter lets an application "append arbitrary fields to the ordinal documents" through an appender, called from the protected, @lucene.experimental hook enrichOrdinalDocument (ReindexingEnrichedDirectoryTaxonomyWriter.java:31-54, DirectoryTaxonomyWriter.java:440-446).
  • A non-aborting rejection. IndexWriter rejects a document with IllegalArgumentException when a term is longer than MAX_TERM_LENGTH (32,766 bytes) or a vector's dimension differs from the field's, and stays open. It has already reserved a doc ID for the document: "Even on exception, the document is still added (but marked deleted), so we don't need to un-reserve at that point" (DocumentsWriterPerThread.java:283-290, deleted at :302-306).

The bug

  1. addCategoryDocument (DirectoryTaxonomyWriter.java:452-482) builds the category document, calls enrichOrdinalDocument(d, categoryPath) (:466), then indexWriter.addDocument(d) (:468), and only then int id = nextID.getAndIncrement() (:469).
  2. If addDocument rejects the document, the exception leaves between :468 and :469: maxDoc is k+1, nextID is still k.
  3. The next category is indexed as doc k+1 and handed ordinal k, and so on for every later category until a merge reclaims the deleted document. The writer's cache and parent array use its own ordinals (:475, :479), so nothing fails inside the writer while its cache still holds the labels it added (true at repro scale). By code reading, not executed: once the 4,000-entry cache (:89) evicts a label added after the rejection, the writer looks it up on disk and finds its doc ID (:347-377, :484-494), so the same session can return a second ordinal for that label.
  4. Readers resolve ordinals by doc ID: ordinal k is the deleted, rejected document (getPath returns its label, or throws IllegalStateException when it has no doc values, as in the vector case), and getSize() returns numDocs, one less than maxDoc (DirectoryTaxonomyReader.java:429-456, :591-594). Main-index documents store the writer's ordinals (FacetsConfig.java:383), so they are counted under the previous category.
  5. A reopened writer starts at nextID = maxDoc (DirectoryTaxonomyWriter.java:174) and looks labels up by doc ID, so it returns 3 for a category the first writer returned as 2.
  6. Enrichment fields are the only demonstrated trigger and the only route through the stock writer's own fields (an openIndexWriter override may supply another IndexWriter, DirectoryTaxonomyWriter.java:203-217, not explored): FacetLabel.MAX_CATEGORY_PATH_LENGTH (8,191 characters, FacetLabel.java:32-39, :90-100) keeps a category's own term below the limit. An exception thrown by the appender itself, before addDocument, is harmless (control test).

The numbers

Facet scenario (this doc's test): color/x is doc 2 and committed; the document for color/bad (32,767-byte description) is rejected and stays as deleted doc 3; color/y, size, size/s, size/t are docs 4-7 but get ordinals 3-6. Reader: getSize() 7, maxDoc 8. FastTaxonomyFacetCounts.getTopChildren(10, "color") returns bad (2), x (1) instead of y (2), x (1). Writer view: after a (ordinal 1) and the rejected rejected, addCategory("b") returns 2; the reader says ordinal 2 is [rejected] and [b] is 3; a reopened writer returns 3 for b. With a 3-dimensional vector after 2-dimensional ones: the same 2 vs 3, and getPath(2) throws IllegalStateException. Same with assertions on and off, on main and 10.5.2.

Exposure and likelihood385 words
  • Who is exposed. Users of the enrichment hook (@lucene.experimental, 9.10.0 and later) whose enrichment value can be rejected inside IndexWriter.addDocument (two executed triggers: an unbounded per-category text indexed as a StringField longer than 32,766 bytes, and a vector whose dimension differs from earlier category documents, for example after switching embedding models), and who keep using the writer after addCategory or FacetsConfig.build threw. For these non-aborting failures IndexWriter stays usable and documents that its own index stays consistent; no taxonomy javadoc says whether the taxonomy writer may be reused. The taxonomy writer's own stock IndexWriterConfig; single-threaded; the test framework randomizes the codec, the main index's config and the directories, and 40 of 40 bug-test runs fail with --iters 10. A plain DirectoryTaxonomyWriter cannot hit it.
  • Downstream. A read-only code search of the default branches of Elasticsearch, OpenSearch and Solr found no use of DirectoryTaxonomyWriter; custom applications that embed the facet module are the ones exposed.
  • Effect. Until a merge reclaims the deleted document, every category added after the rejection resolves to the previous one, across dimensions too (a probe's lang dimension showed {} instead of {en=1}). Counts go to the wrong labels silently (color: bad (2), x (1); with 1,100 earlier categories and a selective query, sparse counting, nothing throws either). Other shapes throw ArrayIndexOutOfBoundsException; after a vector rejection every taxonomy facet request throws CorruptIndexException: Missing parent data for category 3, and a restarted writer cannot add a new category. Each further rejection adds one more position (after 3: writer 3, reader 6).
  • Persistence. The wrong ordinals are stored in main-index documents and committed. Within one writer session, a merge that drops the deleted document realigns that session's ordinals; if a new writer reopened the taxonomy before that merge, one set of stored ordinals stays wrong for good. Once a reopened writer's ordinals are stored as well, repair means rebuilding the taxonomy and re-indexing the affected documents (inference from the design, not executed).
  • Not affected. Drill-down filters use path terms, not ordinals, and matched exactly the right documents; no document is exposed; no hang or unbounded resource use.
  • Likelihood. Rare: it needs the opt-in hook, a value IndexWriter rejects, and continued use of the writer. The only upstream test of the hook adds small valid fields.

Minimal repro

The test shows that after IndexWriter rejects one enriched ordinal document (a 40,000-byte term), DirectoryTaxonomyWriter returns an ordinal for the next category that the taxonomy reader maps to a different category.

TestTaxonomyWriterRejectedOrdinalDocument.java (JUnit test)55 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.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.facet.taxonomy.FacetLabel;
import org.apache.lucene.store.AlreadyClosedException;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTaxonomyWriterRejectedOrdinalDocument extends LuceneTestCase {

  public void testOrdinalAfterRejectedOrdinalDocument() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      DirectoryTaxonomyWriter tw =
          new ReindexingEnrichedDirectoryTaxonomyWriter(
              dir,
              (label, doc) -> {
                if (label.components[0].equals("bad")) {
                  doc.add(new StringField("big", "x".repeat(40_000), Field.Store.NO));
                }
              });
      expectThrows(IllegalArgumentException.class, () -> tw.addCategory(new FacetLabel("bad")));
      int b;
      try {
        b = tw.addCategory(new FacetLabel("b"));
      } catch (AlreadyClosedException e) {
        return; // a writer that closes itself after the rejection hands out no wrong ordinal
      }
      tw.close();
      try (DirectoryTaxonomyReader reader = new DirectoryTaxonomyReader(dir)) {
        assertEquals(
            "taxonomy ordinal of [b] vs the ordinal addCategory returned for it",
            reader.getOrdinal(new FacetLabel("b")),
            b);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-520/, 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.TestTaxonomyWriterRejectedOrdinalDocument
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected: addCategory(b) returns b's ordinal in the taxonomy (2), or the writer refuses further adds. Actual (-ea and -da): it returns 1, the docID of the rejected, deleted document.

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

More files, environment, full output44 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: taxonomy ordinal of [b] vs the ordinal addCategory returned for it 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.apache.lucene.facet.taxonomy.directory.TestTaxonomyWriterRejectedOrdinalDocument.testOrdinalAfterRejectedOrdinalDocument(TestTaxonomyWriterRejectedOrdinalDocument.java:48)

Why it looks unintended

  • TaxonomyWriter.addCategory: "If the category was already present in the taxonomy, its existing ordinal is returned." A reopened writer returns 3 for a category that got 2.
  • The writer assumes "categories are never deleted from the taxonomy index"; the rejected document is a deleted one.
  • After this non-aborting rejection IndexWriter stays usable; no javadoc asks for a rollback.
The full argument321 words
  • Contract. TaxonomyWriter.java:51-53: "addCategory() adds a category with a given path name to the taxonomy, and returns its ordinal. If the category was already present in the taxonomy, its existing ordinal is returned." No exception carve-out; the reopen test breaks it with the writer alone.
  • The writer's design. "internal docids of the configured index must not be altered. For that, categories are never deleted from the taxonomy index" (DirectoryTaxonomyWriter.java:227-229); "liveDocs=null because the taxonomy has no deletes" (:364); "there are no deletions in the index" (:628-632).
  • IndexWriter's contract. A term longer than MAX_TERM_LENGTH makes addDocument throw IllegalArgumentException (IndexWriter.java:1480-1481), and after an exception "the index will be consistent, but this document may not have been added" (:1465-1466); IndexWriter stays open after this non-aborting rejection (executed). It closes itself on tragic events, such as "if you hit an Error, or disaster strikes during a checkpoint" (:162-165), an aborting indexing exception (DocumentsWriterPerThread.java:594-605, IndexWriter.java:1634-1639) or a failed merge (:5109-5113, :6090-6105); none of these happens here. The consistency promise covers IndexWriter's own index, not wrappers with their own invariants.
  • The hook's docs. "append arbitrary fields to the ordinal documents" (ReindexingEnrichedDirectoryTaxonomyWriter.java:31-33); "modify the document corresponding to a category path before indexing it" (DirectoryTaxonomyWriter.java:441-442). Nothing restricts the fields or says the writer must be rolled back after a rejection; the rollback() javadoc only describes rollback (:855-859).
  • Tests and history. #12337, which added the hook, and its issue and dev@ design thread never discuss fields that IndexWriter rejects; its only test, TestOrdinalData, adds small valid fields. LUCENE-5048 (#6112, 2013) capped label length so that a label's terms stay below the indexer limit (the comment at FacetLabel.java:32-39 names the drill-down term), which also keeps a category's own term below it; the hook added a second, uncapped way to put terms into the same document.
The devil's-advocate check199 words

Strongest case (the devil's advocate; the cross-model intended-lens reviewer rated it moderate): the hook is @lucene.experimental; "arbitrary fields" should mean valid fields; the application built the field that IndexWriter rejected and received an IllegalArgumentException; rollback() exists; IndexWriter only promises that the document "may not have been added".

What to concede: no API expressly promises that the taxonomy writer stays usable after an unchecked exception caused by the caller's enrichment, and IndexWriter's consistency promise covers its own index, not the taxonomy's ordinal invariants. Why it fails anyway: @lucene.experimental reserves the right to change the API (asked for in the #12337 review "so we reserve the right to break them on subsequent releases"), not to corrupt ordinals. The argument explains the one failed call, not the silent wrong ordinals of every later, valid category, nor one category getting two ordinals across sessions. "Roll back after any exception" is documented nowhere and is stricter than the IndexWriter the writer wraps, and the writer breaks its own stated invariant (no deleted documents). A rare trigger lowers severity, not intent. Verdict: a defect. Expect the reply "validate your fields or roll back"; answer with the addCategory contract and the reopen test.

Suggested fix

Recommended Fail closed: roll back when the rejected document used up a doc ID

Fail closed: when indexWriter.addDocument throws after IndexWriter used up a doc ID (maxDoc != nextID), roll the taxonomy writer back and rethrow (+20/-1). The new test passes 6 of 6 and the facet module's 312 tests pass, assertions on and off. Callers then get AlreadyClosedException and lose uncommitted categories.

try {
  indexWriter.addDocument(d);
} catch (Throwable t) {
  // (abridged) a non-aborting failure still uses up a doc ID: roll back
  try {
    if (indexWriter.isOpen() && indexWriter.getDocStats().maxDoc != nextID.get()) {
      rollback();
    }
  } catch (Throwable t2) {
    t.addSuppressed(t2);
  }
  throw t;
}
int id = nextID.getAndIncrement();
All options and trade-offs5 options ยท 880 words

Option A was built and validated with gradle in a scratch worktree; option B was run and is not enough; options C to E were not run. Until a fix lands, applications can validate enrichment values inside the appender, where throwing is harmless (not a complete guard: analyzer output, the current field schema and codec limits cannot all be checked outside IndexWriter), and keep the enrichment schema fixed.

Fail closed: roll back when the rejected document used up a doc ID (validated by the checks) (recommended)

One file, DirectoryTaxonomyWriter.java. Wrap indexWriter.addDocument(d) in addCategoryDocument: if it throws while IndexWriter is open and its maxDoc no longer equals nextID, call rollback() (which closes the taxonomy writer) and rethrow, adding a secondary failure as suppressed. Plus a javadoc paragraph on enrichOrdinalDocument saying so, and that an exception thrown by the hook itself leaves the writer usable. The logic is the finder's sketch; the snippet abridges the patch's two-line comment (the full text is in the patch and in the brief).

try {
  indexWriter.addDocument(d);
} catch (Throwable t) {
  // (abridged) a non-aborting failure still uses up a doc ID: roll back
  try {
    if (indexWriter.isOpen() && indexWriter.getDocStats().maxDoc != nextID.get()) {
      rollback();
    }
  } catch (Throwable t2) {
    t.addSuppressed(t2);
  }
  throw t;
}
int id = nextID.getAndIncrement();

Trade-offs

  • Why rollback: the writer can neither remove the deleted document nor reuse its doc ID. Only a later merge reclaims it, so a writer that keeps going commits a taxonomy with a hole that readers resolve wrongly until then, and a reopen before that merge leaves one set of ordinals wrong for good. The last commit is the one consistent state the writer can reach by itself. The rejected document never reaches a commit.
  • Behaviour change: callers that continue today get AlreadyClosedException on their next call and must open a new writer. Every uncommitted category is lost, and main-index documents indexed since the last taxonomy commit must be discarded too (taxonomy first, then main index, TaxonomyWriter.java:36-42), since their ordinals will be handed out again. Executed in the diagram probe with the fix: a main-index color/x document, indexed before the rejection and committed, stores ordinal 2, which the reopened writer hands to color/y, so it is counted as y.
  • Unchanged: an exception from the hook itself, before addDocument, leaves the writer usable; an aborting failure that closes IndexWriter is rethrown as before; the normal path only gains a try. Not benchmarked; not reviewed upstream.
  • By code reading, not tested: addCategory checks ensureOpen() and the cache outside the lock (DirectoryTaxonomyWriter.java:381-388) and rollback() sets isClosed only after indexWriter.rollback() returns (:861-864), so only calls that start after the rollback reliably get AlreadyClosedException; a concurrent cache hit can still return an uncommitted ordinal. If indexWriter.rollback() itself throws, the taxonomy writer is not marked closed. No targeted tests for these, for a tragic failure, or for a coordinated rollback of both writers; maintainers may prefer a poison-first variant (like option C) or a revised version that also blocks the cache fast path.
  • Taxonomies already damaged are not repaired. DirectoryTaxonomyWriter.java is byte-identical from 10.0.0 to main; 9.x needs its own (same) patch.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25: the new test fails 4 of 6 on stock and passes 6 of 6 with the fix, assertions on and off; with TestDirectoryTaxonomyWriter and TestOrdinalData: 24 tests, 0 failures (1 skipped).
  • The whole facet module: 40 suites, 312 tests (306 upstream + 6 new), 0 failures, with assertions on (7 skipped) and off (8 skipped); :lucene:facet:check -x test (google-java-format, ecjLint, forbiddenApis) passes.
  • Standalone runner with the patched class: the new test passes (assertions on and off) and the finder's JUnit passes; the independent reproducer's 4 bug tests get AlreadyClosedException, because they keep using the writer after the rejection, which this fix forbids (its control passes).

Re-sync the counter to maxDoc (not enough) (validated by the checks)

After a failed addDocument, set nextID to maxDoc and rethrow.

Trade-offs

The deleted document stays: the reader's getSize() (numDocs) is one short of the highest ordinal. By code reading the hole also keeps the rejected label readable, lacks parent data in the vector case, and a later merge that drops it shifts every later doc ID again (not run with this variant).

What the checks validated

Gradle on main: the three ordinal tests pass, but the facet-count test throws ArrayIndexOutOfBoundsException: Index 7 out of bounds for length 7 in FastTaxonomyFacetCounts.count.

Fail-stop without rollback

Mark the writer broken, like IndexWriter's tragic-event handling, so addCategory, commit and close throw until the application calls rollback().

Trade-offs

Same data loss as the recommended option, more code paths, and the application must know to roll back. Not run.

Tolerate a permanent hole

Count maxDoc instead of numDocs, give the hole placeholder parent data, and use a merge policy that never reclaims the deleted document.

Trade-offs

Invasive: it changes the reader, the taxonomy arrays and the merge policy. Not run, not recommended.

Document that enrichment values must be valid and that a rejection requires a rollback

Javadoc only, on the hook and on ReindexingEnrichedDirectoryTaxonomyWriter.

Trade-offs

No code risk, but nothing stops the silent corruption.

Downstream note

An application that copies unbounded caller data into enrichment fields lets one rejected request mislabel the facet results of everyone who shares that taxonomy, while drill-down filters stay correct.

How it was found

TLA+ model checking of the taxonomy writer's add path, with a non-aborting IndexWriter rejection modelled after DocumentsWriterPerThread. The finder formed the hypothesis while mapping failure points to the code; the model checker then showed the counter falling behind the documents after one rejection, and the trace was reduced to a JUnit on the stock jars.

The method in full170 words

Method: TLA+ model checking (TLC 1.8.0) (semi-formal tier: a hypothesis formed while modelling, then witnessed by the tool), in a hunt unit on concurrent adds to the taxonomy writer.

  • The model (TaxoWriterCache.tla, 518 lines) covers addCategory, the writer's cache, its reader and parent array, with optional failure points; the rejection step follows DocumentsWriterPerThread's non-aborting path (the document keeps its doc ID and is deleted).
  • The invariant SizeMatchesDocs (the writer's counter equals the number of taxonomy documents) fails after one rejection in all 8 rejection configurations (new and existing taxonomy, cache sizes 1, 2, 3 and unbounded; 15 states for the first one), while all 96 configurations without injected failures or rejections keep every invariant. The model is bounded; it corroborates the Java finding and proves nothing about the Java code.
  • The trace was reduced to a same-package JUnit on the stock jars, then checked by an independent reproducer, a reviewer from another model family (OpenAI GPT-5.6, reading only), a devil's advocate, an adjudication and an audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): GitHub issues and PRs, JIRA, four mailing lists, downstream projects, the web and all 409 open PRs. The PR that added the hook (#12337) never discusses rejected fields; LUCENE-5048 (#6112) capped label length so a category's own term cannot be rejected.

Searches4 searches

Verdict new. The nearest items added the hook, capped the label length, or fixed other causes of wrong ordinals; none describes a rejected ordinal document.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (open and closed, migrated LUCENE issues), commits, downstream repositories2026-10-26three runs of a scripted prior-report search (48 GitHub, 6 commit, 2 downstream queries) and 31 GraphQL searches (apache/lucene, lucene-solr, Elasticsearch, OpenSearch, Solr, all of GitHub)no report; ReindexingEnrichedDirectoryTaxonomyWriter finds only #12337, enrichOrdinalDocument nothing; related #12337, #12336, #6112 (LUCENE-5048), #9427 (LUCENE-8380), #5228, #5133, #5661, #6306
JIRA archive, dev@, java-user@, issues@, commits@2026-10-2612 JIRA and 60 list queries through the tool, plus 7 direct JQL and 44 direct list-archive queriesno report; the 2023-05 dev@ design thread for the hook does not discuss failures
Web2026-10-2610 queriesno report; two Stack Overflow pages unreadable, both about code without the hook
Open apache/lucene PRs2026-10-26all 409 open PRs listed with their changed filesnone touches ReindexingEnrichedDirectoryTaxonomyWriter.java; #13951 only adds a cast in DirectoryTaxonomyWriter.createIndexWriterConfig

History

Introduced in 9.10.0 by #12337 (merged 2024-02-08), which added the enrichment hook and so made a 2011 statement order reachable. Every release from 9.10.0 to 10.5.2 and main is affected; 9.9.2 lacks the API; never fixed.

Versions tested6 rows

Introduced by #12337 "Index arbitrary fields in taxonomy docs" (stefanvodita, for #12336), merged 2024-02-08: main f339e24e8ed6, branch_9x backport 2d713d904d01; CHANGES 9.10.0: "GITHUB#12336: Index additional data per facet label in the taxonomy." git log -S enrichOrdinalDocument finds only this commit. Older code it made reachable: addDocument before nextID++ dates from the faceting module's port (4291f52c70d7, LUCENE-3079, 2011), and IndexWriter's keep-the-doc-ID-and-delete comment from 858e309c7b1 (LUCENE-5843, 2014); before #12337 the stock writer's own fields could not make IndexWriter reject an ordinal document. Not fixed: DirectoryTaxonomyWriter.java is the same blob in 10.0.0, 10.5.2, a9779ef3c193, upstream main a95c754ee00, branch_10x and branch_10_6; the 9.x file has the same order.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 4 of 6 (gradle and standalone, assertions on and off); passes 6 of 6 with the fix
10.5.2 affectedthis doc's test fails 4 of 6, assertions on and off; the finder's and the independent reproducer's tests fail too
9.10.0, 9.12.3, 10.0.0 affectedthe finder's JUnit fails 2 of 2 and the independent reproducer's 4 of 5 (9.10.0 also with assertions off)
9.9.2 and earlier not affectedthe enrichment API is absent (both repros fail to compile); a plain-writer control passes
9.9.2 jars + only the #12337 source hunks commit-level checkboth repros fail the same way: #12337 alone exposes the older code
fix (scratch worktree at a9779ef3c193) not affected6 of 6; facet module 312 tests, 0 failures

Independent checks

Confirmed by an independent reproducer, 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. The refute-lens review produced no verdict.

Checks and verdicts270 words

Each executing check wrote and ran its own test on the stock jars; the controls pass everywhere.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn 5-test JUnit outside the taxonomy package: 4 fail on main (assertions on and off), 10.5.2, 9.12.3 and 9.10.0, the control passes; 40 of 40 with --iters 10. Found the cross-session contract break (2, then 3) and, with a merge probe, that merges move the error rather than repair it.
cross-model review, intended lens real, medium; intended argument moderateRead the code only; root cause agrees. A separate run re-ran the finder's JUnit: 2 of 2 fail. A separate probe counted color/y documents under the rejected label (bad (2), x (1)).
cross-model review, refute lens no verdictNo attempt returned a verdict.
devil's advocate real; no not-a-bug argument holdsFive not-a-bug arguments checked. Own probe: b session1=2 b session2=3. Corrected the persistence claim: a merge realigns a single session; a reopen before the merge makes it permanent.
adjudication confirmed, high; duplicate of noneRe-ran the independent reproduction's JUnit on main with assertions on and off: 4 of 5 fail, control passes.
registry re-runs and audit keepFresh seeds on main and 10.5.2: the independent reproduction's JUnit fails 4 of 5, the finder's 2 of 2; impact class upheld.
this doc's runs reproduced; fix validatedNew 6-test JUnit: 4 fail on main (gradle and standalone) and 10.5.2, assertions on and off; re-syncing the counter alone leaves the facet-count failure; the fail-closed fix 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,482 words

Finding record

LFV-520, snapshot of 2026-11-06

Registry title
DirectoryTaxonomyWriter gives every later category a wrong ordinal after IndexWriter rejects one enriched ordinal document: the rejected document keeps its docID but the writer's ordinal counter does not advance
Area
facet / taxonomy writer (DirectoryTaxonomyWriter.addCategoryDocument with the ordinal-document enrichment hook: ReindexingEnrichedDirectoryTaxonomyWriter / enrichOrdinalDocument)
Priority
Low, rank 317: Needs the opt-in experimental ordinal-enrichment hook plus a rejected enrichment field and continued use of the writer.
Impact class
data corruption
Impact note
Only applications that use the opt-in, @lucene.experimental ordinal-document enrichment (ReindexingEnrichedDirectoryTaxonomyWriter or an enrichOrdinalDocument override, 9.10.0 and later; no use found in Elasticsearch, OpenSearch or Solr) are hit, and only after IndexWriter rejects one enriched ordinal document with a non-aborting exception (an indexed term over 32766 bytes or a vector of another dimension, both executed) while the application keeps using the same writer: from then on every new category gets an ordinal one below its docID, deterministically, single-threaded, at any size and with the default codec and IndexWriterConfig otherwise, those ordinals are written into main-index documents and committed, and facet labels and counts go to the wrong categories (silently with large taxonomies and selective queries [exec]) or faceting throws; a merge that drops the deleted ordinal document realigns that writer session's ordinals, but once a new writer has reopened the taxonomy before that merge, one set of ordinals stays wrong for good.
Summary
An application adds its own fields to taxonomy ordinal documents with ReindexingEnrichedDirectoryTaxonomyWriter (or an enrichOrdinalDocument override), IndexWriter rejects one category's field (for example a term over 32766 bytes or a vector of a different dimension), addCategory throws IllegalArgumentException, and the application skips that category and keeps going. You expect only that category to be missing and every later addCategory to return the category's ordinal, the one a taxonomy reader reports for it, with an existing category keeping its ordinal (TaxonomyWriter.addCategory). Instead IndexWriter keeps the rejected document as a deleted document that still uses up a docID while the writer's ordinal counter does not move, so every later category gets an ordinal one below its docID: readers resolve it to the previous category (even the rejected label), facet counts land under the wrong labels or throw ArrayIndexOutOfBoundsException or CorruptIndexException, and a reopened writer returns 3 instead of 2 for a category that already exists.
Introduced
f339e24e8ed64bf64ef4bb05111d252ecdacde1e (main) and its branch_9x backport 2d713d904d011767f5e98bbc763f84a0b4fa508f: apache/lucene#12337 'Index arbitrary fields in taxonomy docs' (author stefanvodita, for issue #12336; CHANGES 9.10.0 'GITHUB#12336: Index additional data per facet label in the taxonomy') [read: git log -S enrichOrdinalDocument finds only this commit, git blame puts the hook lines DirectoryTaxonomyWriter.java:446 and :465-466 on it; exec: stock 9.9.2 jars plus only the #12337 source hunks, via repro.sh --shadow-src, fail the same way (finder's JUnit 2 of 2 with -ea and -da, the independent reproduction's 4 of 5) while the plain-writer control passes]. It added the first way to put arbitrary, rejectable fields into an ordinal document and so made older code reachable: 'indexWriter.addDocument(d); int id = nextID++;' dates from 4291f52c70d7 (2011-06-29, LUCENE-3079, the faceting module port; nextID.getAndIncrement() since bcc116057d1, #11775, 2022), and IndexWriter's keep-the-docID-and-delete behaviour for non-aborting failures is documented in DocumentsWriterPerThread since 858e309c7b1 (2014, LUCENE-5843); before #12337 the category's own term could not be rejected because FacetLabel.MAX_CATEGORY_PATH_LENGTH (LUCENE-5048, a287431293e, 2013) keeps it below IndexWriter.MAX_TERM_LENGTH (2024-02-08 (#12337 merged 2024-02-08T11:02:12Z; PR opened 2023-05-27))
First affected version
9.10.0 [exec] (first release with enrichOrdinalDocument and ReindexingEnrichedDirectoryTaxonomyWriter; 10.0.0 [exec] is the first affected 10.x release); not affected: 9.9.2 and earlier, where the API is absent (both repros fail to compile on 9.9.2 and the plain-writer control passes [exec])
Affected versions
every release from 9.10.0 through 10.5.2 (the last 10.x release before 10.6.0), including 9.12.3 (latest 9.x), plus main ea57360912ed; never fixed. [exec] fails: 9.10.0 (-ea and -da), 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), each with the finder's JUnit (2 of 2) and the independent reproduction's JUnit (4 of 5, control passes). [read] 9.11.0, 9.11.1, 9.12.0-9.12.2 and 10.1.0-10.5.1 by tag containment of the introducing commits and unchanged code of both writer files; upstream main, branch_10x and branch_10_6 have no commit on the two files after #12337 (GitHub commits API, 2026-10-26). 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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only; follow-up prior-report record verdict new): three scripted prior-report searches (the finder's run and the follow-up's mechanism and symptom variants: 48 GitHub issue/PR queries on apache/lucene, 6 commit, 2 downstream-repository, 60 mailing-list queries over dev@, java-user@, issues@ and commits@, 12 JIRA-archive and 6 web queries; 0 high hits), 31 GitHub GraphQL searches (apache/lucene, the old lucene-solr repository, Elasticsearch, OpenSearch, Solr and all of GitHub: ReindexingEnrichedDirectoryTaxonomyWriter finds only #12337, enrichOrdinalDocument nothing), 44 direct list-archive and 7 direct JIRA queries, 10 web queries in total, the local history clone, and the changed files of all 409 open apache/lucene PRs (none touches ReindexingEnrichedDirectoryTaxonomyWriter.java; the only open PR on DirectoryTaxonomyWriter.java, #13951, adds a cast in createIndexWriterConfig). Closest, not the same bug: #12337 (added the hook; body, comments and reviews never discuss fields that IndexWriter rejects or a failed addCategory), #12336 and its 2023-05 dev@ design thread, #6112 (LUCENE-5048: capped the label length so a category's own term cannot hit the indexer limit), #9427 (LUCENE-8380: wrong ordinals from a since-removed cache bug), #5228, #5133, #5661 and #6306 (older DirectoryTaxonomyWriter getSize, concurrency and replaceTaxonomy work). Not fixed upstream (both writer files unchanged since #12337).
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 release jars (this doc's test). The hunt, the independent checks and the registry re-runs ran on main ea57360912ed, whose facet and core files involved here are byte-identical, and on the 9.10.0, 9.12.3, 10.0.0 and 10.5.2 release jars; 9.9.2 (API absent) and 9.9.2 plus only the #12337 source hunks mark the boundary. The recommended fix was validated in the scratch worktree: the new test, TestDirectoryTaxonomyWriter, TestOrdinalData and the whole facet module (312 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/DirectoryTaxonomyWriter.java:227-229 (at ea57360912ed) (NOTE: 'internal docids of the configured index must not be altered. For that, categories are never deleted from the taxonomy index'), :364 (findCategory: 'liveDocs=null because the taxonomy has no deletes'), :440-446 (protected enrichOrdinalDocument hook, @lucene.experimental), :452-469 (addCategoryDocument: enrichOrdinalDocument :466, indexWriter.addDocument :468, nextID.getAndIncrement :469 only after a successful add), :627-632 (perhapsFillCache: 'there are no deletions in the index'), :665-681 (getTaxoArrays builds TaxonomyIndexArrays from the index)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java:31-55 (at ea57360912ed) ('append arbitrary fields to the ordinal documents', @lucene.experimental, the appender called from enrichOrdinalDocument)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:52-53 (at ea57360912ed) (addCategory 'returns its ordinal. If the category was already present in the taxonomy, its existing ordinal is returned')
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java:590-594 (at ea57360912ed) (getSize() returns indexReader.numDocs()) and TaxonomyIndexArrays.java:212-216 (CorruptIndexException 'Missing parent data for category')
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:283-289 (at ea57360912ed) ('Even on exception, the document is still added (but marked deleted)', reserveOneDoc before processing) and IndexWriter.java:1465-1466 ('the index will be consistent, but this document may not have been added'), :1480-1481 (a term longer than MAX_TERM_LENGTH throws IllegalArgumentException), :258 (MAX_TERM_LENGTH = BYTE_BLOCK_SIZE - 2)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FacetLabel.java:38-39, (at ea57360912ed) :90-100 (MAX_CATEGORY_PATH_LENGTH = (BYTE_BLOCK_SIZE - 2) / 4 = 8191 characters) and FacetsConfig.java:383 (FacetsConfig.build passes addCategory's exception to the caller)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :89, :174, :181-182, :203-217, :227-229, :364, :381-402, :440-446, :452-482, :347-377, :484-494, :628-632
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java :31-54
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java :283-290, :302-306
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :258, :1480-1481
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyReader.java :270-313, :429-456, :591-594
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java :383
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FacetLabel.java :32-39, :90-100
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java :103-122, :134, :490-494
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/TaxonomyIndexArrays.java :212-216
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestOrdinalData.java (the hook's only upstream test)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java :51-53
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :227-229, :364, :441-442, :628-632, :855-859
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :162-165, :1465-1466, :1480-1481, :1634-1639, :5109-5113, :6090-6105; lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java :594-605
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/ReindexingEnrichedDirectoryTaxonomyWriter.java :31-33
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FacetLabel.java :32-39
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java :36-42; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :296-298, :381-388, :855-865
  • a Lucene clone (DirectoryTaxonomyWriter.java blob bd4934fbb04 identical in 10.0.0, 10.5.2, a9779ef3c193, origin/main a95c754ee00, origin/branch_10x, origin/branch_10_6; 9.12.3 has the same order at :511-514)
History
  • a Lucene clone (blobs of both writer files in the release tags, origin/main, origin/branch_10x, origin/branch_10_6)

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 made in parallel with this doc from an executed probe of the facet scenario on main a9779ef3c193 (stock and fix, assertions on and off). The diagram's probe uses a 40,000-byte desc term; the test uses 32,767 bytes, one over the limit; both are rejected the same way.

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.