All findings · Lucene finding · LFV-521 · taxonomy-writer-addcategory-not-failure-atomic

DirectoryTaxonomyWriter gives an existing category a second ordinal after an IOException in addCategory

If DirectoryTaxonomyWriter hits an IOException while opening its internal reader in addCategory, it keeps trusting its cache, so re-adding an existing label creates a second ordinal: dim/v0 ends up at ordinals 2 and 4001, counted 1 instead of 2. commit() persists it, and CheckIndex reports the taxonomy clean.

Summary

Priority
Low, rank 313Default taxonomy writer, but it needs a non-fatal IOException in the narrow window where the writer opens its NRT reader, plus continued use of the writer.
Impact
data corruption
Since
3.4.0 (2011) with an evicting LRU cache; default writer from 4.1.0 and 9.5.0introduced 2011-06-29 · over 15 years
Affected
every release from 3.4.0 to 10.5.2, and main
Status
documented
Found by
TLA+ model checking of the writer's cache protocol (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

The taxonomy writer gives every facet label a number, its ordinal, and caches label-to-ordinal entries. A flag says the cache holds every label, so a cache miss can be taken to mean "new label". The bug: after a failed reader open, the writer keeps trusting that flag, or its old reader.

Diagram of DirectoryTaxonomyWriter's 4,000-entry label cache, its cacheIsComplete flag and its internal reader: the 4,001st label evicts the least recently used entries, including dim/v0 at ordinal 2; the reader refresh throws an IOException, so the flag stays set; the next document with dim/v0 misses in the cache and is indexed again as ordinal 4001; after commit the facet count for dim/v0 is 1 instead of 2.
The writer's label cache, its "complete" flag and its internal reader, step by step through the failing add and the duplicate ordinal it leaves behind. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 3,998 documents with FacetField("dim", "v0") to ("dim", "v3997") through FacetsConfig.build and a default DirectoryTaxonomyWriter. Its label cache (4,000 entries) is now full; dim/v0 has ordinal 2.
  2. The next new value evicts the 1,335 least recently used cache entries, dim/v0 among them, so the writer refreshes its internal reader. Opening a segment file fails once with an IOException, as a failed memory map would. IndexWriter stays open; the application skips that document.
  3. You index one more document with dim/v0 and expect its count to be 2: addCategory returns the existing ordinal of a label that is already there.
  4. Instead the writer's "cache is complete" flag is still set, because it is cleared only after the refresh succeeds. The miss counts as new: dim/v0 gets ordinal 4001, and getSpecificValue("dim", "v0") returns 1.
  5. commit() persists both ordinals and CheckIndex reports the taxonomy clean. A second path: once an existing taxonomy is read into the cache, a failed reader open while adding the label new makes the retry return 14 instead of 13.

Who is affected

Applications that index taxonomy facets with a default DirectoryTaxonomyWriter and keep using it after addCategory or FacetsConfig.build threw an IOException (skip or retry the document). The failure must hit the writer's internal reader open, which is rare. The duplicate it leaves is silent: CheckIndex misses it, and once committed it stays until the taxonomy is restored or rebuilt.

Mechanism, terms and numbers674 words

DirectoryTaxonomyWriter keeps a label-to-ordinal cache (LruTaxonomyWriterCache(4000) by default since 9.5.0) and a flag, cacheIsComplete, that lets it treat a cache miss as a new label. When a put evicts entries, addToCache first refreshes the writer's near-real-time reader and only then clears the flag. If that refresh throws an IOException, for example because a segment file cannot be opened, the flag stays set although a third of the cache was just evicted, and IndexWriter stays open. The next addCategory of an evicted label indexes it again under a new ordinal, and commit() persists both: dim/v0 at ordinals 2 and 4001, facet count 1 instead of 2, CheckIndex clean. A second path: after an existing taxonomy is read into the cache, addCategoryDocument indexes a new category before it opens a reader for the parent array; if that open throws, a retry gives the category a second ordinal (13 and 14). Every release from 3.4.0 (with an evicting LRU cache) to 10.5.2 and main is affected; with the default writer from 4.1.0 (second path) and 9.5.0 (first path).

Terms

  • Taxonomy and ordinal. Taxonomy facets store each label, such as dim/v0, once in a side index, as one document; its document number is the label's ordinal. Documents in the main index store ordinals, and facet counts are kept per ordinal. TaxonomyWriter.addCategory must return the existing ordinal of a label that is already there.
  • Cache and the "complete" flag. DirectoryTaxonomyWriter caches label-to-ordinal entries. The default LruTaxonomyWriterCache(4000) evicts: when a put makes it exceed 4,000 entries, it drops the least recently used ones down to 2,666, so the 4,001st entry evicts 1,335. While the flag cacheIsComplete is set, a cache miss means "new label" and nothing is looked up on disk.
  • The writer's reader. To answer a miss when the cache is not complete, the writer looks the label up through a near-real-time reader of its own index. After an eviction it must refresh that reader, because evicted labels may have been added after the reader was opened.

Site A: the refresh after an eviction (default writer since 9.5.0)

  1. addToCache (DirectoryTaxonomyWriter.java:484-494): cache.put reports an eviction; the writer calls refreshReaderManager() (:491) and only then sets cacheIsComplete = false (:492).
  2. The refresh reaches IndexWriter.getReader, which flushes and then opens the new segments. If opening a file throws an IOException, getReader rethrows it without closing IndexWriter (it treats only an Error as tragic, IndexWriter.java:711-721). The flag stays set and the reader stays old.
  3. The next addCategory(dim/v0) misses in the cache; findCategory returns the miss at once because the flag is set (:327), and the label is indexed again as ordinal 4001.
  4. Clearing the flag first is not enough: the on-disk lookup and the cache fill (perhapsFillCache) would then use the old reader, which does not see the evicted labels either. Nothing on the addCategory path refreshes it until the next eviction.

Site B: the reader open after a complete cache fill (default writer since 4.1.0)

  1. A reopened taxonomy reads all its labels into the cache after 11 misses; if they all fit, it sets the flag and closes its reader (:652-662).
  2. The next new label is indexed (:468) before the writer re-opens a reader for the parent array (getTaxoArrays(), :475); the cache update comes last on purpose (:477-479, LUCENE-4596). If that open throws, the label is in the index but not in a cache that is still marked complete, and a retry indexes it again: new at 13 and 14.

The numbers

Default writer and FacetsConfig: 3,998 values fill the cache (root, dim and 3,998 values); the 3,999th document's refresh fails and the document is skipped; one more dim/v0 document is indexed. Committed taxonomy: 4,002 ordinals, dim/v0 at 2 and 4001; FastTaxonomyFacetCounts.getSpecificValue("dim", "v0") = 1 instead of 2. Writer view: addCategory("l0") returns 4001 instead of 1. Site B: the retry returns 14 instead of 13. Same with assertions on and off, on main and 10.5.2.

Exposure and likelihood371 words
  • Who is exposed. Users of the opt-in taxonomy facet module with the default writer constructor (and the IndexWriterConfig it builds itself), cache and codec, on any Directory, who continue after a failed addCategory or FacetsConfig.build, as ordinary per-document error handling does: the wrapped IndexWriter stays open, and no javadoc asks for a rollback (none explicitly promises reuse either). A read-only code search of the default branches of Elasticsearch, OpenSearch and Solr found no use of DirectoryTaxonomyWriter; custom applications that embed the module are the ones exposed.
  • What triggers it. A non-Error IOException exactly while the writer opens its reader inside addCategory: the refresh after an eviction (with the default cache about once per 1,335 new categories once it is full; a probe saw 13 reader opens in 20,000 addCategory calls), or the first reader open after an existing taxonomy that fits in the cache was read into it. Realistic causes: a failed memory map (MMapDirectory reports it as a plain IOException) or running out of file handles. A failure in the flush itself closes IndexWriter instead; every repro injects the failure in the reader open after a successful flush. Labels alone never cause it (0 duplicates in 80,000 adds and re-adds).
  • Effect. One label, two category documents, persisted by commit(); CheckIndex reports the taxonomy clean. Rolling back both writers before the next commit discards it (with every other uncommitted change); after a commit it takes a clean backup or a rebuild. Counts for the label are split, getAllChildren lists it twice, and when the evicted label is a dimension, getAllDims lists the dimension twice. Drill-down matching stays correct (it uses path terms): 0 mismatches over 3,998 labels. Merging the damaged taxonomy with addTaxonomy after a one-segment merge mapped the second copy to ordinal 0, or threw EOFException with DiskOrdinalMap.
  • Scale. None needed: the first eviction comes at the 4,001st cache entry.
  • Not a separate bug. The failed call's own document is also persisted, which matches IndexWriter's "this document may not have been added" wording. In the end-user scenario FacetsConfig.build throws before the main index's addDocument, so the taxonomy then holds a category (dim/v3998) that no main-index document uses.

Minimal repro

The test shows that after one addCategory fails because the eviction refresh of the default DirectoryTaxonomyWriter's NRT reader throws an IOException, the writer still trusts its cache as complete and indexes an existing, evicted category again.

TestTaxonomyWriterFailedRefreshDuplicatesOrdinal.java (JUnit test)61 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 java.io.IOException;
import org.apache.lucene.facet.taxonomy.FacetLabel;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTaxonomyWriterFailedRefreshDuplicatesOrdinal extends LuceneTestCase {

  private volatile boolean failNextSegmentReaderOpen;

  public void testExistingCategoryKeepsOrdinalAfterFailedEvictionRefresh() throws Exception {
    try (Directory dir =
        new FilterDirectory(new ByteBuffersDirectory()) {
          @Override
          public IndexInput openInput(String name, IOContext context) throws IOException {
            if (failNextSegmentReaderOpen
                && StackWalker.getInstance()
                    .walk(
                        s ->
                            s.anyMatch(
                                f ->
                                    f.getClassName()
                                        .equals("org.apache.lucene.index.SegmentCoreReaders")))) {
              failNextSegmentReaderOpen = false;
              throw new IOException("simulated I/O error opening the near-real-time reader");
            }
            return super.openInput(name, context);
          }
        };
        DirectoryTaxonomyWriter tw = new DirectoryTaxonomyWriter(dir)) {
      for (int i = 1; i < 4000; i++) {
        tw.addCategory(new FacetLabel("l" + i));
      }
      failNextSegmentReaderOpen = true;
      expectThrows(IOException.class, () -> tw.addCategory(new FacetLabel("x")));
      // expected: the existing category l1 keeps ordinal 1; actual: it is indexed again as 4001
      assertEquals("ordinal returned for existing category l1", 1, tw.addCategory(new FacetLabel("l1")));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-521/, 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.TestTaxonomyWriterFailedRefreshDuplicatesOrdinal
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected addCategory(l1) to return l1's existing ordinal 1 (TaxonomyWriter.addCategory contract); actual: a new ordinal, expected:<1> but was:<4001>, with -ea and -da.

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

More files, environment, full output39 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: ordinal returned for existing category l1 expected:<1> but was:<4001>
       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.TestTaxonomyWriterFailedRefreshDuplicatesOrdinal.testExistingCategoryKeepsOrdinalAfterFailedEvictionRefresh(TestTaxonomyWriterFailedRefreshDuplicatesOrdinal.java:58)

Why it looks unintended

  • TaxonomyWriter.addCategory: "If the category was already present in the taxonomy, its existing ordinal is returned." No exception carve-out.
  • The cache javadoc: a miss proves absence only if the cache is complete "and since then no put() returned true". Here put() returned true.
  • The writer stays open, commit() persists the duplicate, and upstream fixed this outcome as a bug before (LUCENE-4061).
The full argument362 words
  • Contract. TaxonomyWriter.java:51-53: "If the category was already present in the taxonomy, its existing ordinal is returned." TaxonomyWriterCache.java:30-32 (the same rule in other words at :55-58): the caller "can only infer that the category doesn't exist if it knows the cache to be complete (because all the categories were loaded into the cache, and since then no put() returned true)". The writer's own comments: the cache "becomes incomplete ... if a put() to the cache ever returned true" (DirectoryTaxonomyWriter.java:110-117); after an eviction "we must now refresh the reader" (:486-490); "when this method returns, we must know that the reader manager's state is current" (:497-499); "each term has exactly one document" (:366, :628-629).
  • The wrapped IndexWriter keeps working after non-fatal exceptions. It closes itself only "if you hit an Error, or disaster strikes during a checkpoint" (IndexWriter.java:162-165); after an exception in addDocument "the index will be consistent, but this document may not have been added" (:1465-1466). Nothing says the taxonomy writer must be discarded after an exception (nor, explicitly, that it may be reused: continued use is implicit); the rollback() javadoc only describes rollback (DirectoryTaxonomyWriter.java:855-859).
  • Site A is the clean case. The label that gets the second ordinal (dim/v0, l0) was added successfully long before; the failed call was for a different label.
  • Tests. No facet test injects I/O failures into the taxonomy writer, so the exception path is untested, not waived.
  • Precedent. Upstream fixed "an existing category gets a second ordinal" as a bug three times; in none did the duplicate come from a failed addCategory call: LUCENE-4061 (#5133, 2012; a statement-order bug skipped the refresh; shaie: "NoOpCache in this case simulates the case where e.g. LruTaxoWriterCache just evicts entries from it and we fail to refresh the reader, so this is a true bug"), LUCENE-4633 (#5698, replaceTaxonomy left the reader stale) and LUCENE-5048 (#6112, a huge label re-added with a different ordinal; it was reported as a NegativeArraySizeException from the old cache inside addCategory, but the second ordinal came from a short cast on the label length, not from the exception).
The devil's-advocate check234 words

Strongest case (the devil's advocate; the cross-model intended-lens reviewer rated it moderate): both classes are @lucene.experimental (TaxonomyWriter.java:47, DirectoryTaxonomyWriter.java:76); no exception-safety guarantee is documented; rollback() exists and TwoPhaseCommit frames it as the recovery tool; IndexWriter itself says a document "may not have been added" after an exception; the facet module never had failure-injection tests; the trigger window is narrow.

Why it fails: @lucene.experimental permits API changes, not duplicate ordinals; the addCategory contract and the cache rule are unconditional, and the code breaks its own stated invariants; "roll back after any IOException" is documented nowhere, is stricter than the IndexWriter the writer wraps (which stays usable after such exceptions), and would discard every uncommitted category for a failure IndexWriter survives; the damage is silent and persisted; upstream treated the same symptom as a bug before. In site A the duplicated label was added successfully long before the failed call, so "the failed call may not have added its document" does not cover it. A rare trigger lowers severity, not intent. For site B the rebuttal is weaker: the retried label is the one whose call threw, and a maintainer could reasonably prefer fail-stop semantics (option D); there it rests on the missing documentation, not on a documented reuse guarantee. Verdict: a defect. Expect the reply "roll back after an IOException"; answer with the contract, the cache rule and the IndexWriter exception contract.

Suggested fix

Recommended Clear the flag first and track a possibly stale reader (validated)

Clear cacheIsComplete before the eviction refresh, and mark the reader as possibly stale until a refresh succeeds, also when anything after indexWriter.addDocument throws; findCategory then refreshes it before the cache fill and the on-disk lookup (+29/-7). The new test passes 5 of 5 and the facet module's 311 tests pass, assertions on and off.

// addToCache, after cache.put(...) evicted entries
cacheIsComplete = false; // before the refresh, which may throw
readerMayMissCategories = true;
refreshReaderManager();
readerMayMissCategories = false;

// addCategoryDocument: catch (Throwable t) after the document is indexed
cacheIsComplete = false; readerMayMissCategories = true; throw t;

// findCategory, before perhapsFillCache() and the on-disk lookup
if (readerMayMissCategories) {
  refreshReaderManager(); // no-op when no reader manager is open
  readerMayMissCategories = false;
}
All options and trade-offs5 options ยท 755 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 in Java. Do not revert LUCENE-4596's order (the cache entry must stay last).

Clear the flag first and track a possibly stale reader (validated) (recommended)

One file, DirectoryTaxonomyWriter.java. A new volatile field readerMayMissCategories. addToCache sets cacheIsComplete = false and the new flag before refreshReaderManager(), and clears the new flag once the refresh returns. addCategoryDocument wraps the parent-array update and addToCache (order unchanged) and, if either throws, clears cacheIsComplete, sets the new flag and rethrows. findCategory, after counting the miss, refreshes the reader when the new flag is set, before perhapsFillCache() and the on-disk lookup.

// addToCache, after cache.put(...) evicted entries
cacheIsComplete = false; // before the refresh, which may throw
readerMayMissCategories = true;
refreshReaderManager();
readerMayMissCategories = false;

// addCategoryDocument: catch (Throwable t) after the document is indexed
cacheIsComplete = false; readerMayMissCategories = true; throw t;

// findCategory, before perhapsFillCache() and the on-disk lookup
if (readerMayMissCategories) {
  refreshReaderManager(); // no-op when no reader manager is open
  readerMayMissCategories = false;
}

Trade-offs

  • Keeps LUCENE-4596's order: the cache entry is still published last, so a lock-free cache hit never sees an ordinal whose parent is not stored yet.
  • A retry of the failed label finds its already indexed document and returns that ordinal (13); the failed call's document stays, as IndexWriter's wording allows.
  • Cost: one extra reader refresh on the first cache miss after a failure; on the normal path a volatile read per cache miss and two writes per eviction. Not benchmarked.
  • Taxonomies that already hold a duplicate are not repaired; they need a rebuild.
  • DirectoryTaxonomyWriter.java is byte-identical from 10.0.0 to main; the 9.x files differ but have the same two orders, so a 9.x backport needs its own patch. Only main was built with gradle.
  • Scope: it covers failures after indexWriter.addDocument succeeded (the reader opens), not an exception from addDocument itself, which is a different root cause tracked separately.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25: the new test fails 4 of 5 on stock and passes 5 of 5 with the fix, assertions on and off; with TestDirectoryTaxonomyWriter: 20 tests, 0 failures.
  • The whole facet module: 40 suites, 311 tests (306 upstream + 5 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, the finder's 3 tests, the independent reproducer's tests and control, and the end-user test and control all pass, assertions on and off (the second mode of the finder's, the independent reproduction's and the intended-behaviour check's suites was run by the fact check). In the finder's bounded TLA+ model the same logic has no violation in any of 96 configurations: the 84 with one injected failure check the six observable invariants (no duplicate label, sizes, add results, parents, cache soundness), the 12 failure-free ones also the protocol invariant LookupComplete, which the fix deliberately relaxes after a failure. The model abstracts the reader as a prefix of the indexed documents: corroboration, not a proof.

Only move cacheIsComplete = false above the refresh (not enough) (validated by the checks)

The obvious two-line swap in addToCache.

Trade-offs

The lookup and the cache fill then use the reader whose refresh failed, which does not see the evicted labels; site B is unchanged. The finder's model shows the same, and a first sketch that refreshed only before the on-disk lookup failed the finder's stale-fill test (testEvictionRefreshFailureThenFillFromStaleReader, "expected:<1> but was:<4>").

What the checks validated

Gradle on main: all 4 failing tests still fail with the same values.

Refresh in findCategory whenever a refresh is pending

Refresh whenever shouldRefreshReaderManager is set, not only after a failure.

Trade-offs

Correct in the finder's model (variant fixFull), but it reopens the near-real-time reader on cache misses after any new label. Not run in Java.

Fail-stop: close the taxonomy writer on such a failure

Treat an exception after indexWriter.addDocument, or a failed eviction refresh, as fatal: roll back and close, so later calls throw AlreadyClosedException and nothing is committed.

Trade-offs

Loses every uncommitted label and forces the application to reopen both writers; stricter than IndexWriter, which survives these exceptions. Not run.

Document that the writer must be rolled back after an IOException

Javadoc only.

Trade-offs

No code risk, but nothing stops the silent corruption, and rollback discards uncommitted labels.

Downstream note

Elasticsearch, OpenSearch and Solr do not use DirectoryTaxonomyWriter (code search of their default branches), so the exposed users are applications that embed Lucene's taxonomy facets directly.

How it was found

TLA+ model checking of the writer's cache, completeness flag and reader, with optional I/O failures. The finder noticed the statement order while mapping the model to the code; the model checker then gave minimal counterexamples, which were reduced to a JUnit on the stock jars.

The method in full189 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 covers addCategory, the LRU cache with its exact eviction rule, the lazily opened reader, cacheIsComplete, perhapsFillCache and the parent array, with optional I/O failure points. Without failures, every invariant holds in all 96 bounded configurations. The model is bounded and abstracts the reader as a prefix of the indexed documents, so it corroborates the Java finding rather than proving anything about the Java code.
  • With one injected failure, the protocol invariant LookupComplete fails in 37 of 48 configurations; minimal traces: a refresh failure after an eviction (18 states) and a reader re-open failure after a complete cache fill (9 states). Clearing the flag first is not enough (NoDuplicateLabel fails), and the stale-reader cache fill came from the model too.
  • The traces were reduced to a same-package JUnit on the stock jars, then checked 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.

Prior reports, history and checks

Prior reports

No prior report found (217 executed queries, 2026-10-26): GitHub issues and PRs, JIRA, the mailing lists, downstream projects and all open PRs. Closest precedent: LUCENE-4061 (#5133, fixed 2012), where a skipped refresh, with no exception, gave existing categories second ordinals.

Searches4 searches

Verdict new. The nearest items share the invariant (one ordinal per label) but have other triggers and fix sites.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (open and closed, migrated LUCENE issues), commits2026-10-2636 REST queries of a scripted prior-report search, 55 GraphQL searches, 6 commit-message queries; class and method names, symptom and failure phrasingsno report; precedents #5133 (LUCENE-4061), #5698 (LUCENE-4633), #6112 (LUCENE-5048); related #5661 (LUCENE-4596), #12092 / #12093
JIRA archive, dev@, java-user@, issues@, commits@2026-10-2614 JQL and 92 list queries over three runsno report
Elasticsearch, OpenSearch, Solr, Lucene.NET, archived lucene-solr; web2026-10-264 REST and 9 GraphQL searches; 6 web queriesno report; apache/lucenenet#1444 is a hash-collision cache defect
Open apache/lucene PRs2026-10-26all 408 open PRs listed; changed files of the 12 facet-related ones read#16847, #12862, #16862 touch the files but not the affected methods

History

The eviction-refresh path dates from the facet module's first commit (LUCENE-3079, 2011, released in 3.4.0); the reader-open path from LUCENE-4596 (2012, 4.1.0). The evicting cache became the default in 9.5.0 (#12093). Every release from 3.4.0 to 10.5.2 and main is affected; it was never fixed.

Versions tested8 rows

Site A came with LUCENE-3079 "add faceting module" (#4152): d4e1a420d52 on branch_3x (committed by shaie) and the trunk port 4291f52c70d (committed by rmuir), 2011-06-29; LUCENE-4061 (2012) renamed the refresh call and kept the order. Site B came with LUCENE-4596 "DirectoryTaxonomyWriter concurrency bug" (#5661): bc61e7cdc9b / 2bde91dc0d2, 2012-12-09, which moved the cache update after the parent array on purpose; commit-level runs isolate that hunk. Default exposure of site A from #12093 (9.5.0) / #12092 (10.0.0) by vigyasharma, 2023-01-18, which made the evicting LruTaxonomyWriterCache(4000) the default. DirectoryTaxonomyWriter.java last changed 2024-02-08 (#12337); upstream main and branch_10x still have both orders.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 4 of 5 (gradle and standalone, assertions on and off); passes 5 of 5 with the fix
10.5.2 affectedthis doc's test fails 4 of 5, assertions on and off
9.5.0, 9.12.3, 10.0.0 affectedthe finder's JUnit fails 3 of 3 (9.5.0, 9.12.3); history driver: both sites with the default cache
4.1.0 to 9.4.2 (4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.4.2) affectedsite B with the default writer; site A only with an explicit LRU cache (the default cache never evicts)
3.4.0, 3.6.2, 4.0.0 explicit LRU cache onlysite A with an explicit LRU cache; no site B (the cache was updated before the parent array)
3.3.0 not affectedno facet module
4.0.0 / 4.1.0 with only the LUCENE-4596 hunk applied / reverted bisect4.0.0 + hunk: site B fails; 4.1.0 - hunk: site B passes
fix (scratch worktree at a9779ef3c193) not affected5 of 5; facet module 311 tests, 0 failures

Independent checks

Confirmed by an independent reproducer with a different injection, a devil's advocate with an end-user facet-count test, 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 verdicts441 words

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

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn JUnit with a plain FilterDirectory keyed to segment-reader opening: l0 at 1 and 4001, new at 13 and 14, on main, 10.5.2 and 9.12.3, assertions on and off; control passes; the fix makes all pass while the injection still fires.
cross-model review, intended lens real, high; intended argument moderateRead the code only; root cause agrees, inputs within the contract. A separate run re-ran the finder's JUnit: 3 of 3 fail.
cross-model review, refute lens no verdictIt returned no verdict, and neither did earlier attempts.
devil's advocate real; high that it is not intendedEnd-user JUnit (FacetsConfig.build, FastTaxonomyFacetCounts, one skipped document): count 1 instead of 2 on main and 10.5.2; control passes.
adjudication confirmed, high; duplicate of noneRe-ran the independent reproduction's JUnit on main with assertions on and off: 2 of 2 fail; control passes.
registry re-runs and audit keepFresh seeds on main: the finder's JUnit fails 3 of 3 with assertions on and off and passes with the fix; impact class upheld.
this doc's runs reproduced; fix validatedNew 5-test JUnit: 4 fail on main (gradle and standalone) and 10.5.2, assertions on and off; the swap-only fix changes nothing; the recommended fix passes the test and the facet module.
fact check of this doc (independent) reproduced; 4 text errors, fixedRe-ran the doc's repro with the standalone runner on main (assertions on and off) and 10.5.2, the paste-ready gradle command in a clean copy of a9779ef3c193 (4 of 5 fail, both modes), the recommended fix (5 of 5 pass) and the swap-only fix (the same 4 failures), plus the fix shadow runs the brief had covered in one assertion mode only. Errors: the MatchAllDocsQuery.INSTANCE version range, the LUCENE-5048 precedent wording, the patch size (+29/-7) and the history-run count; all fixed.
fact check of this doc (OpenAI GPT-5.6, read-only) core finding holds; wording correctedA reading-only review: 8 factual errors (0 high, 6 medium, 2 low), 7 overclaims and 5 missing caveats, none of which changes the finding; it rated the "intended" case weak for site A and moderate for site B. Every item was fixed or answered; its call to downgrade novelty was not taken, with the reason.
diagram check passEvery value, API name, code line and order of events in the PNG matched the code and the diagram probe (stock and fix, assertions on and off).

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

Finding record

LFV-521, snapshot of 2026-11-06

Registry title
DirectoryTaxonomyWriter gives an existing category a second ordinal after an addCategory call fails with an IOException while the writer opens its near-real-time reader, because the cache stays marked complete or the reader stays stale
Area
facet / taxonomy writer (DirectoryTaxonomyWriter.addCategory exception safety: cacheIsComplete and reader-manager freshness after a failed near-real-time reader open)
Priority
Low, rank 313: Default taxonomy writer, but it needs a non-fatal IOException in the narrow window where the writer opens its NRT reader, plus continued use of the writer.
Impact class
data corruption
Impact note
Applications that index taxonomy facets with the default DirectoryTaxonomyWriter (its default LruTaxonomyWriterCache(4000) since 9.5.0 for site A; for site B any cache, on an existing taxonomy small enough to be read into the cache; default IndexWriterConfig, codec and Directory) and keep using the writer after an addCategory or FacetsConfig.build call threw are hit when that IOException came from opening the writer's near-real-time reader (the refresh after a cache eviction, or the lazy reader open after an existing taxonomy was read into the cache), for example a failed memory map or running out of file handles: a category already in the taxonomy is indexed again under a new ordinal, commit() persists both, CheckIndex reports the taxonomy clean, and facet counts and listings for that label are split or doubled (executed: getSpecificValue 1 instead of 2) until the taxonomy is rebuilt, while drill-down matching stays correct; no scale is needed, and an I/O failure in the flush itself is tragic and closes the writer instead.
Summary
You index facet categories with a default DirectoryTaxonomyWriter; one addCategory call throws an IOException because the writer could not open its near-real-time reader (for example on the call whose new category makes the 4000-entry cache evict), and you keep using the writer, as IndexWriter allows after a non-fatal exception. You expect a later addCategory of a category that is already in the taxonomy to return its existing ordinal, as TaxonomyWriter.addCategory promises. Instead the writer still trusts its cache as complete (or its old reader), indexes the category again and returns a new ordinal (l0: 4001 instead of 1; a retried 'new': 14 instead of 13), and commit() persists one label under two ordinals, so its facet counts are split between them.
Introduced
Two sites. Site A (addToCache calls refreshReaderManager() before 'cacheIsComplete = false', so a refresh that throws leaves the cache marked complete right after an eviction): the facet module's first commit, LUCENE-3079 'add faceting module' (now #4152, committed by shaie), d4e1a420d525ecf022ee8c3594f3683005c5cc85 (branch_3x, svn r1141060) and its trunk port 4291f52c70d7b6324456663cc3a7ec0df855ccb7 (svn r1141246, rmuir) [read: git blame of DirectoryTaxonomyWriter.java:485 and :492 at ea57360912ed gives 4291f52c70d, :491 the LUCENE-4061 rename 1db88a8665d; LUCENE-4061 (b4c22fcf37e, 3e7339af688, 1db88a8665d, 2012) renamed the call without changing the order; no earlier version exists]. Site B (addCategoryDocument indexes the document and advances nextID before the fallible getTaxoArrays() and addToCache): LUCENE-4596 'DirectoryTaxonomyWriter concurrency bug' (now #5661, by shaie), bc61e7cdc9b43f8eff78da42d96481cdfd4aa288 (trunk, svn r1418860) and 2bde91dc0d2a8d2a29072bd36a19949d2ff07524 (branch_4x, svn r1418873), which moved addToCache last on purpose to fix a concurrency bug [exec: commit-level shadow runs; 4.1.0 with only that hunk reverted passes scenario B, 4.0.0 with only that hunk applied fails it]. Default-configuration exposure of site A: dc33ade76d36834ffe7b862f966ca31414712490 (#12092, main) and 9d6173148d7a7c25128f86ee61fca00ac88baaf7 (#12093, branch_9x), both by vigyasharma, addressing #12000, which made the evicting LruTaxonomyWriterCache(4000) the default [exec: default cache not triggered on 9.4.2, fails on 9.5.0] (2011-06-29 (site A, LUCENE-3079); 2012-12-09 (site B, LUCENE-4596); 2023-01-18 (#12092 / #12093: site A reachable with the default cache))
First affected version
3.4.0 (2011-09-14) [exec], with an explicitly configured LruTaxonomyWriterCache (site A); with the default configuration 4.1.0 (2013-01-21) [exec] (site B), and site A also from 9.5.0 (2023-01-25) [exec]; 3.3 and earlier have no facet module [read: no taxonomy sources in the 3.3 tag; exec: lucene-facet 3.3.0 is not on Maven Central]
Affected versions
every release before 10.6.0 with the facet module, 3.4.0 through 10.5.2, plus main ea57360912ed; never fixed. Site A with an explicit LRU cache: 3.4.0 - 10.5.2 and main [exec on 3.4.0, 3.6.2, 4.0.0, 4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main; read: the same order at all 128 release tags with the module]. Site A with the default cache: 9.5.0 - 10.5.2 and main [exec on 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main]; not triggered on 3.4.0 - 9.4.2, whose default caches never evict (Cl2oTaxonomyWriterCache to 7.0.1, UTF8TaxonomyWriterCache 7.1.0 - 9.4.2) [exec]. Site B: 4.1.0 - 10.5.2 and main [exec on 4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main]; not affected 3.4.0 - 4.0.0, which cache the category before touching the parent array [exec on 3.4.0, 3.6.2, 4.0.0]. The finder's 3-test JUnit fails 3 of 3 on main, 10.5.2, 9.12.3 and 9.5.0 with -ea, and with -da on main, 10.5.2 and 9.12.3 (on 9.4.2 test 1 fails only because its trigger is unreachable with the non-evicting default cache) [exec]; the independent reproduction's own JUnit fails 2 of 2 on main, 10.5.2 and 9.12.3 with -ea and -da [exec]; -ea and -da agree everywhere both were run. DirectoryTaxonomyWriter.java is unchanged from f339e24e8ed (#12337, 2024-02-08, in 10.0.0) through branch_10_6 and upstream main ffa9d816eeb4 (2026-10-26) [read]. 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: TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, 229 queries, 26 reviewed hits, plus the finder's scripted prior-report search and the intended-behaviour check's list, JIRA and GraphQL searches): apache/lucene issues and PRs, open and closed, including migrated LUCENE issues (36 REST queries through a scripted prior-report search, 55 GraphQL), the JIRA archive (14 JQL), dev@, java-user@, issues@ and commits@ (92 list queries over three runs), commit messages (6), downstream repositories (Elasticsearch, OpenSearch, Solr, the Lucene.NET port, archived lucene-solr; 4 REST and 9 GraphQL), all 408 open apache/lucene PRs (files of the 12 facet-related ones read), the upstream commit log of DirectoryTaxonomyWriter.java and writercache/ up to main ffa9d816eeb4, and 6 web queries. Precedent, not this bug (different trigger and fix site): #5133 (LUCENE-4061, fixed 2012: a statement-order bug skipped the reader refresh after an eviction, with no exception, and existing categories were added again; shaie called it 'a true bug'; the fix moved the shouldRefreshReaderManager assignment), #5698 (LUCENE-4633, fixed 2012: replaceTaxonomy left the reader stale, so an existing category got a new ordinal), #6112 (LUCENE-5048, fixed 2013: a huge label re-added with a different ordinal through a short cast in the old Cl2o cache). Related: #5661 (LUCENE-4596, the deliberate concurrency fix that created site B), #12092 / #12093 (made the evicting cache the default). Cache defects without an exception path: #8942 (LUCENE-7891), #9427 (LUCENE-8380), #9766 (LUCENE-8720), apache/lucenenet#1444. Open PRs touching the files (#16847, #12862, #16862) do not change addToCache, refreshReaderManager, findCategory, perhapsFillCache or addCategoryDocument. Not fixed upstream (DirectoryTaxonomyWriter.java last changed 2024-02-08, #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 DirectoryTaxonomyWriter.java is byte-identical; the history step logged 81 runs from 3.3.0 to main (73 history-driver runs, 4 of them not applicable on 3.3.0, which has no facet jar, plus 8 runs of the finder's JUnit). The recommended fix was validated in the scratch worktree: the new test, TestDirectoryTaxonomyWriter and the whole facet module (311 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:76 (at ea57360912ed) (@lucene.experimental), :89 and :270-276 (DEFAULT_CACHE_SIZE = 4000; defaultTaxonomyWriterCache returns LruTaxonomyWriterCache), :110-118 (cacheIsComplete comment: 'The cache becomes incomplete ... if a put() to the cache ever returned true'), :323-330 (findCategory returns a miss directly when cacheIsComplete), :339-345 (perhapsFillCache, then a miss is trusted again), :351-377 (on-disk lookup through the reader manager; 'it has exactly one document' :366), :452-482 (addCategoryDocument: indexWriter.addDocument :468, nextID.getAndIncrement :469, getTaxoArrays().add :475, LUCENE-4596 comment and addToCache last :477-479), :484-494 (addToCache: eviction comment :486-490, refreshReaderManager() :491 before cacheIsComplete = false :492), :496-508 (refreshReaderManager: 'when this method returns, we must know that the reader manager's state is current' :497-499; maybeRefresh :505 before shouldRefreshReaderManager = false :506), :604-663 (perhapsFillCache: cacheIsComplete = !aborted :652, reader manager closed :653-662), :665-684 (getTaxoArrays lazily calls initReaderManager :672), :855-865 (rollback javadoc and body)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:47 (at ea57360912ed) (@lucene.experimental), :52-53 ('If the category was already present in the taxonomy, its existing ordinal is returned.')
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/writercache/TaxonomyWriterCache.java:22-32 (at ea57360912ed) (class javadoc: 'The caller can only infer that the category doesn't exist if it knows the cache to be complete (because all the categories were loaded into the cache, and since then no put() returned true)'), :52-58 (get(): the same rule)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:160-165 (at ea57360912ed) (IndexWriter closes itself only on an Error or a checkpoint disaster), :711-721 (getReader: onTragicEvent only for an Error, then rethrow), :1462-1467 (addDocument: after an Exception the index is consistent, the document may not have been added)
  • lucene/CHANGES.txt:3670-3671 (at ea57360912ed) (9.5.0, GITHUB#12093: default changed to LruTaxonomyWriterCache), :2143 (10.0.0, #12092: UTF8TaxonomyWriterCache removed)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :89, :101, :110-118, :193, :274-276, :323-378, :381-402, :452-482, :484-494, :496-508, :604-663, :665-684
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/writercache/LruTaxonomyWriterCache.java :105-117
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/writercache/NameIntCacheLRU.java :55 (access-ordered LinkedHashMap), :91-93, :110-116
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java :51-53
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :613-615, :711-721
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java :383
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java :585-603 (a flush failure is aborting and tragic)
  • lucene/core/src/java/org/apache/lucene/store/MMapDirectory.java :376-379, :431-457
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :162-165, :1465-1466
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/writercache/NameIntCacheLRU.java :114
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java :47, :51-53
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/writercache/TaxonomyWriterCache.java :29-32, :55-58
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java :76, :110-117, :366, :486-490, :497-499, :628-629, :855-859
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :162-165, :1465-1466
  • lucene/facet/src/test (grep failOn|setRandomIOExceptionRate: no hits)
  • https://github.com/apache/lucene/issues/5133 (LUCENE-4061 comment, read 2026-10-27)
  • https://github.com/apache/lucene/issues/6112 (LUCENE-5048 report: NegativeArraySizeException from the Cl2o cache; read 2026-10-27)
Suggested fix
  • a Lucene clone release tags (DirectoryTaxonomyWriter.java blob identical from 10.0.0 to main; the 9.x blobs differ but keep both orders, history tag scan)
History
  • lucene/CHANGES.txt :3686-3687 (9.5.0, GITHUB#12093), :2159-2160 (10.0.0)
  • a Lucene clone (origin/main a95c754ee00, origin/branch_10x 1309534296e: no change to the file after 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 made in parallel with this doc from the executed repro on main a9779ef3c193. Re-rendered after the diagram check (wording and timing polish only; no value changed).

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.