All findings · Lucene finding · LFV-489 · searchertaxonomymanager-nrt-ctor-opens-taxonomy-before-index

SearcherTaxonomyManager's IndexWriter constructor opens the taxonomy before the index, so its first pair can be mismatched

A SearcherTaxonomyManager built from the writers while another thread indexes opens the taxonomy before the index, so its first pair can hold a document whose category the taxonomy reader does not know. Facet counts over that document then throw ArrayIndexOutOfBoundsException until the index changes; every release since 4.3.0 has it.

Summary

Priority
Low, rank 610A loud facet-counting ArrayIndexOutOfBoundsException from a fairly likely construction-time race, but it is transient and module-only.
Impact
crash
Since
4.3.0 (LUCENE-3786, 2013), when the class was addedintroduced 2013-04-10 · over 13 years
Affected
every release run from 4.3.0 to 10.6.0 (15) and main; 4.2.1 has no such class
Status
documented
Found by
TLA+ model checking of the reader pair, then a public-API JUnit

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 constructor takes the taxonomy snapshot before the index snapshot. A document with a new category that is indexed in between is in the searcher but not in the taxonomy reader.

Background

  1. Two indexes. Taxonomy facets keep a second index, the taxonomy, that gives each category a number, its ordinal (0 is the root). A document in the main index stores the ordinals of its categories.
  2. Append-only. Categories are only ever added. So a taxonomy reader that is newer than the index reader is safe, and an older one may miss ordinals the documents use. That is why TaxonomyReader's javadoc says to open the taxonomy after the main index.
  3. The manager. SearcherTaxonomyManager hands out SearcherAndTaxonomy pairs, an IndexSearcher plus a DirectoryTaxonomyReader, and refreshes them near-real-time from the two writers.
Summary card. The constructor opens the taxonomy reader, which knows ordinals 0 to 2 (root, Author, Author/Alice), before the index reader. Another thread adds Author/Bob as ordinal 3 and its document in between, so the first pair's searcher has 2 documents while its taxonomy reader has 3 ordinals; getOrdinal(Author/Bob) is -1 and facet counting throws ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3. maybeRefresh() keeps the pair until the index changes. The fix opens the index reader first, as refreshIfNeeded does.
Six steps on one timeline: the matched pair; the manager built while another thread indexes Author/Bob; inside the constructor, the taxonomy snapshot first and the index snapshot second; counting running off the end of a 3-slot counts array; maybeRefresh() keeping the pair; the index-first fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index one document tagged Author/Alice, so the taxonomy holds 3 ordinals: 0 (root), 1 (Author), 2 (Author/Alice). Then you create new SearcherTaxonomyManager(writer, true, null, taxoWriter) while another thread indexes a document tagged Author/Bob, which becomes ordinal 3.
  2. You expect the first pair, documented as "a matched pair", to count Alice=1 Bob=1: its taxonomy reader should know every category its searcher's documents use.
  3. Instead the constructor opens the taxonomy reader first and the index reader second, and Bob's document lands in between: the searcher sees 2 documents, the taxonomy reader knows only ordinals 0-2, and getOrdinal(Author/Bob) returns -1.
  4. FastTaxonomyFacetCounts over all documents of that pair throws ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3, with assertions on and off.
  5. maybeRefresh() keeps this pair until the index changes again (it returns true: there is nothing new to refresh). The manager's own refresh opens the index first, as TaxonomyReader's javadoc requires; doing the same in the constructor fixes it.

Who is affected

Applications that use taxonomy facets with SearcherTaxonomyManager's IndexWriter constructors and build a manager while another thread indexes new categories: indexing that starts first, a lazily built manager, or one re-created after replaceTaxonomy, which the javadoc requires. Rare: a manager built before indexing starts cannot hit it. Loud and transient; no index damage.

Mechanism, terms and numbers575 words

SearcherTaxonomyManager's IndexWriter (near-real-time) constructors open new DirectoryTaxonomyReader(taxoWriter) before DirectoryReader.open(writer, applyAllDeletes, false). A document indexed by another thread in between, together with a new category, is visible to the first searcher while the paired taxonomy reader does not know its category. getOrdinal returns -1 for it, FastTaxonomyFacetCounts over the pair throws ArrayIndexOutOfBoundsException when the counted hits include that document, and maybeRefresh() keeps the pair until the index changes again. TaxonomyReader's javadoc and the manager's own refreshIfNeeded both open the index first for exactly this reason. Query-time only, no index damage; it needs the manager to be built while new categories are being indexed. Present since the class was added in 4.3.0; executed failures on 15 releases from 4.3.0 to 10.6.0 and on main. Swapping the two opens fixes it.

The code (main 645d4fe14a20, lucene/facet/src/java/org/apache/lucene/facet/taxonomy/)

  1. Indexing a faceted document adds its new categories to the taxonomy writer (FacetsConfig.build) and then the document to the main IndexWriter, from any thread; IndexWriter is "completely thread safe" (IndexWriter.java:167-168).
  2. The constructor body, shared by all three IndexWriter constructors (SearcherTaxonomyManager.java:57-103): new DirectoryTaxonomyReader(taxoWriter) takes the taxonomy snapshot first (:93); then DirectoryReader.open(writer, applyAllDeletes, false) (:96-97), which calls writer.getReader(...) (DirectoryReader.java:141-144), takes the index snapshot.
  3. The window. A document whose addDocument completes between the two snapshots is in the searcher, but its new categories were added after the taxonomy snapshot, so getOrdinal returns INVALID_ORDINAL (-1) for them.
  4. Counting fails when the counted hits include such a document. With dense counting (always used for a taxonomy of fewer than 1024 ordinals, TaxonomyFacets.java:104) FastTaxonomyFacetCounts counts in its constructor into an array sized taxoReader.getSize() (TaxonomyFacets.java:134), and counts[(int) ordinal]++ (FastTaxonomyFacetCounts.java:93) throws for the unknown ordinal. With sparse counting (1024 or more ordinals and hits under a tenth of maxDoc, TaxonomyFacets.java:103-122) the counting itself succeeds (IntIntHashMap.addTo, FastTaxonomyFacetCounts.java:97, :110), and getTopChildren throws when it looks up the parent of the unknown ordinal (TaxonomyFacets.java:482; Index 1104 out of bounds for length 1104 with 1,104 categories). By code reading, getPath of an unknown ordinal throws IllegalArgumentException. A facet count whose hits exclude the document succeeds on the same pair.
  5. Refresh keeps it. refreshIfNeeded opens the index first (SearcherTaxonomyManager.java:195) and returns null when nothing changed (:196-197), so maybeRefresh() keeps the bad pair. The first refresh after the next index change opens the index, then the taxonomy (:204), and gives a matched pair.

What it does not do

For ordinals the stale taxonomy reader does know, the labels are right, because categories are append-only; drill-down terms are built without a taxonomy reader. This is an argument from the code, not an exhaustive test. Searches that do not count facets do not read the taxonomy reader, and a DrillDownQuery builds its terms without one (DrillDownQuery.java:50-51, :116-119); taxonomy-backed DrillSideways does take the taxonomy reader and counts with it through FastTaxonomyFacetCounts (DrillSideways.java:89-90, :157-166), so its facet counts fail the same way (code reading, not run). Only readers are opened, so the index is not damaged.

Scope

Claimed for the IndexWriter constructors only. The Directory constructor has the same order, but its javadoc NOTE already says it can give an "unsync'd" pair unless commit and maybeRefresh() run in the same thread (SearcherTaxonomyManager.java:105-111). The constructors that take already opened readers use the caller's pair.

Exposure and likelihood196 words
  • Who. Taxonomy facets (an opt-in module) with SearcherTaxonomyManager(IndexWriter, ...), otherwise default IndexWriterConfig, codec and searcher. The manager must be built while another thread indexes documents that add new categories: indexing that starts before the search side, a manager created lazily, or one re-created after DirectoryTaxonomyWriter.replaceTaxonomy, which the class javadoc requires ("you must open a new SearcherTaxonomyManager afterwards", SearcherTaxonomyManager.java:37-38).
  • Effect. Facet counts on the first pair whose hits include such a document fail with ArrayIndexOutOfBoundsException (counts that exclude it succeed), and a new category reads as absent (getOrdinal = -1). Under continuous indexing it lasts about one refresh interval; if the index then stays unchanged, it stays.
  • How likely: rare. Applications that build the manager before indexing starts, as upstream's testNRT does, cannot hit it. When construction does overlap indexing of new categories, the window is hit often: 62 to 178 of 300 managers in hook-free stress runs where every document adds a new category (a worst case). No public report of this constructor-order defect was found.
  • Downstream. A read-only code search found no use of SearcherTaxonomyManager or DirectoryTaxonomyReader in Elasticsearch, OpenSearch or Solr. The Lucene.NET port has the same constructor order.

Minimal repro

One JUnit test, one thread, deterministic, public API. An InfoStream on the main writer indexes one document tagged Author/Bob when the constructor's getReader starts, which is after the taxonomy reader was opened. The test checks that the first pair's taxonomy reader knows Author/Bob.

TestSearcherTaxonomyManagerCtorOrder.java (one test, public API)85 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;

import java.io.IOException;
import java.io.UncheckedIOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.facet.FacetField;
import org.apache.lucene.facet.FacetsConfig;
import org.apache.lucene.facet.taxonomy.SearcherTaxonomyManager.SearcherAndTaxonomy;
import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.InfoStream;

public class TestSearcherTaxonomyManagerCtorOrder extends LuceneTestCase {

  private static class AddDocumentAtGetReader extends InfoStream {
    IndexWriter writer;
    DirectoryTaxonomyWriter taxoWriter;

    @Override
    public void message(String component, String message) {
      if (writer != null && message.equals("flush at getReader")) {
        IndexWriter w = writer;
        writer = null;
        Document doc = new Document();
        doc.add(new FacetField("Author", "Bob"));
        try {
          w.addDocument(new FacetsConfig().build(taxoWriter, doc));
        } catch (IOException e) {
          throw new UncheckedIOException(e);
        }
      }
    }

    @Override
    public boolean isEnabled(String component) {
      return "IW".equals(component);
    }

    @Override
    public void close() {}
  }

  public void testFirstPairKnowsTheCategoryOfItsDocument() throws Exception {
    AddDocumentAtGetReader hook = new AddDocumentAtGetReader();
    try (Directory indexDir = newDirectory();
        Directory taxoDir = newDirectory();
        IndexWriter writer = new IndexWriter(indexDir, new IndexWriterConfig().setInfoStream(hook));
        DirectoryTaxonomyWriter taxoWriter = new DirectoryTaxonomyWriter(taxoDir)) {
      hook.taxoWriter = taxoWriter;
      hook.writer = writer;
      try (SearcherTaxonomyManager mgr = new SearcherTaxonomyManager(writer, null, taxoWriter)) {
        SearcherAndTaxonomy pair = mgr.acquire();
        try {
          assertEquals(1, pair.searcher().getIndexReader().numDocs());
          // expected: the pair's taxonomy knows Author/Bob, its document's category; actual: -1
          assertNotEquals(
              "taxonomy ordinal of Author/Bob",
              TaxonomyReader.INVALID_ORDINAL,
              pair.taxonomyReader().getOrdinal("Author", "Bob"));
        } finally {
          mgr.release(pair);
        }
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestSearcherTaxonomyManagerCtorOrder -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
The first pair's searcher sees the document (numDocs 1) and its taxonomy reader returns a valid ordinal for Author/Bob.
Observed
AssertionError: taxonomy ordinal of Author/Bob. Actual: -1, with assertions on and off; facet counting over such a pair throws ArrayIndexOutOfBoundsException.

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

More files, environment, full output392 words

The hook fires on "flush at getReader", which IndexWriter.getReader logs right after ensureOpen() and before it takes any lock or flushes (IndexWriter.java:516-526); the constructor reaches it through DirectoryReader.open(writer, ...) (SearcherTaxonomyManager.java:97), after it opened the taxonomy reader (:93). Indexing there, on the constructing thread, once, creates the ordering a second indexing thread creates when its addDocument completes at that moment; it is a re-entrant call, not ordinary scheduling. Hook-free runs (one thread indexing, managers built in a loop, not part of this repro) show that ordinary scheduling reaches it too: 62 to 178 of 300 managers got a mismatched first pair.

The first assertion (the searcher sees the document, numDocs 1) passes; the second (its category is known) fails. The facet-counting exception follows from the -1 ordinal and is not asserted; the hunt's longer test shows it (Index 3 out of bounds for length 3, below).

Gradle chooses tests.asserts from the seed when the flag is not given (DEADBEEF chooses off); the test fails either way. Run ./gradlew tidy before putting the test in a PR: checkGoogleJavaFormat wants the IndexWriter writer = ... line split over two lines.

Regression test: this same test; it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the index-first fix: passes. Also fails on the 10.6.0 and 10.5.2 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestSearcherTaxonomyManagerCtorOrder.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestSearcherTaxonomyManagerCtorOrder -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, scratch worktree of 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true
TestSearcherTaxonomyManagerCtorOrder > testFirstPairKnowsTheCategoryOfItsDocument FAILED
    java.lang.AssertionError: taxonomy ordinal of Author/Bob. Actual: -1
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:35F52105B867264B]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failEquals(Assert.java:187)
        at junit@4.13.2/org.junit.Assert.assertNotEquals(Assert.java:201)
        at org.apache.lucene.facet.taxonomy.TestSearcherTaxonomyManagerCtorOrder.testFirstPairKnowsTheCategoryOfItsDocument(TestSearcherTaxonomyManagerCtorOrder.java:75)
:lucene:facet:test (FAILURE): 1 test, 1  failure
# the same with -Ptests.asserts=false; the same on the 10.6.0 and 10.5.2 release jars

# the hunt's longer test (the scenario of the steps above), main, seed DEADBEEF; the pair is acquire()d again after maybeRefresh()
first pair: searcher numDocs=2, taxonomy size=3, taxonomy ordinal of Author/Bob=-1;
  counts: [facet counting threw java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3];
  after maybeRefresh() (returned true): [facet counting threw java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3]
  expected:<[Alice=1 Bob=1]>

# with the fix: :lucene:facet:test (SUCCESS): 1 test, assertions on and off

Why it looks unintended

  • TaxonomyReader's javadoc: open the taxonomy "after opening the main index"; the pair is "a matched pair".
  • The manager's own refresh opens the index first, "otherwise we may get a new reader that references ords not yet known to the taxonomy reader".
  • Only the Directory constructor warns of an "unsync'd" pair; the IndexWriter constructors state no precondition.
The full argument329 words
  • Contracts. TaxonomyReader: "it is important to open the taxonomy after opening the main index ... This order is important, otherwise it would be possible for the main index to refer to a category which is not yet visible in the old snapshot of the taxonomy" (TaxonomyReader.java:46-55). SearcherAndTaxonomy: "Holds a matched pair of IndexSearcher and TaxonomyReader", "Matching point-in-time DirectoryTaxonomyReader" (SearcherTaxonomyManager.java:43-50).
  • No caveat on these constructors. "Creates near-real-time searcher and taxonomy reader from the corresponding writers" (:57); the 3-argument one is not marked Expert. The only "unsync'd pair" NOTE is on the Directory constructor (:105-111). SearcherTaxonomyManager is not @lucene.experimental (TaxonomyReader is).
  • The code's own intent. refreshIfNeeded: "Must re-open searcher first, otherwise we may get a new reader that references ords not yet known to the taxonomy reader" (:189-191) and "taxonomy should always be ahead of searchers. Otherwise, searchers might reference ordinals that taxonomy doesn't know of" (:200-202). The first comment (:189-191) came in the commit that created the class (03694e76930, 2013), together with the constructor's opposite order; the second (:200-202) was added by f3796ecf22b6 (#14443, 2025).
  • Maintainers on record. In #6203 (LUCENE-5139, Won't Fix) an application refreshed its two readers separately (sm.maybeRefresh(), then the taxonomy), and its search threads acquired them separately. A maintainer (shaie) suggested reopening the taxonomy before sm.maybeRefresh() there, because "It's usually ok if taxoReader sees more ordinals than IndexSearcher, not vice versa" ("usually": not after replaceTaxonomy with deleteAll()), and recommended SearcherTaxonomyManager. That advice is about publishing two readers separately, not about the snapshot order inside one pair, where TaxonomyReader's javadoc requires the index first. In a 2014 java-user thread they said the manager returns "a sync'd IndexSearcher and DirectoryTaxonomyReader".
  • Tests. None asserts or relies on the taxonomy-first order; TestSearcherTaxonomyManager.testNRT builds the manager (:160) before it starts its indexer thread (:192), so it never exercises the window.
The devil's-advocate check123 words

The strongest case: applications normally build the manager once before indexing starts, as testNRT does; the Directory constructor already warns about unsync'd pairs; the taxonomy APIs are experimental; and LUCENE-5139 put pairing on the application.

Why it fails: no javadoc states a "build before indexing" precondition, IndexWriter is thread safe, and the class javadoc itself requires building a new manager at runtime after replaceTaxonomy; the NOTE is attached only to the Directory constructor; in LUCENE-5139 the application refreshed and published its two readers separately, while here the manager builds one pair itself, and the module's own javadoc and refresh code prescribe the opposite order; no comment, test or issue defends the constructor's order. Cross-model reviews (OpenAI GPT-5.6) rated the works-as-designed case weak.

Suggested fix

Recommended Open the index reader first, then the taxonomy reader (validated)

Open the index reader first and the taxonomy reader second in the constructors, as refreshIfNeeded already does, and close the index reader if the taxonomy open fails. The minimal test then passes, and so does the facet module's test suite.

DirectoryReader reader = DirectoryReader.open(writer, applyAllDeletes, false);
DirectoryTaxonomyReader taxoReader;
try {
  taxoReader = new DirectoryTaxonomyReader(taxoWriter);
} catch (Throwable t) {
  IOUtils.closeWhileSuppressingExceptions(t, reader);
  throw t;
}
current =
    new SearcherAndTaxonomy(
        SearcherManager.getSearcher(searcherFactory, reader, null), taxoReader);
All options and trade-offs3 options ยท 508 words

Option A was built and run; B is a workaround for users today; C is not recommended.

Open the index reader first, then the taxonomy reader (validated) (recommended)

In the IndexWriter constructor body (SearcherTaxonomyManager.java:93-98) open DirectoryReader.open(writer, applyAllDeletes, false) first and new DirectoryTaxonomyReader(taxoWriter) second; the same swap in the Directory constructor (:118-122) is optional, a consistency change: it does not remove that constructor's documented commit/refresh limitation (:105-111). A taxonomy snapshot taken after the index snapshot contains every category the index snapshot's documents use, because categories are added before their document and never removed.

DirectoryReader reader = DirectoryReader.open(writer, applyAllDeletes, false);
DirectoryTaxonomyReader taxoReader;
try {
  taxoReader = new DirectoryTaxonomyReader(taxoWriter);
} catch (Throwable t) {
  IOUtils.closeWhileSuppressingExceptions(t, reader);
  throw t;
}
current =
    new SearcherAndTaxonomy(
        SearcherManager.getSearcher(searcherFactory, reader, null), taxoReader);

Trade-offs

  • Same cost as today: the same two readers are opened, only in the other order.
  • It gives the one-sided invariant the javadoc asks for (the taxonomy reader may be ahead of the index reader, never behind), not an atomic snapshot of both indexes; the class still requires a new manager after replaceTaxonomy.
  • Like the current code, it still leaks the taxonomy reader if the SearcherFactory throws (SearcherManager.getSearcher releases the index reader, SearcherManager.java:262-264); that is a separate bug (registry LFV-309), not part of this fix.
  • The diff is the hunt's fix with two lines wrapped for google-java-format.

What the checks validated

  • This doc's runs: scratch worktree of 645d4fe14a20 with the minimal test copied in, gradle, seed DEADBEEF. Before the fix the test failed with assertions on and off; with the diff, :lucene:facet:test (SUCCESS): 1 test with assertions on and off.
  • Re-run on 2026-10-30 in a fresh scratch worktree: the stock test fails (ordinal -1); with the diff, TestSearcherTaxonomyManager* (the minimal test plus TestSearcherTaxonomyManager's 6 tests) gives SUCCESS: 7 tests, 1 skipped (the @Nightly testNRT) with assertions on and off.
  • The whole facet module with the fix, assertions on: 40 suites, 307 tests, 7 skipped, 0 failures. TestSearcherTaxonomyManager with -Ptests.nightly=true, which adds the @Nightly testNRT (concurrent indexing and refresh through the IndexWriter constructor): 6 of 6 pass.
  • ecjLint and forbiddenApis (main and test) pass; checkGoogleJavaFormat flags only one line of the test file, not the fix.
  • Earlier (hunt, the same swap): every repro passes, the hook-free plain-threads program gives 0 of 300 mismatched pairs, upstream TestSearcherTaxonomyManager 5 of 5, and the same swap on the 4.3.0 source passes there.
  • Not run: the full Lucene suite, benchmarks (the fix opens the same two readers, in the other order).

Workaround for users: open the readers yourself, index first

Open DirectoryReader.open(writer) first, then new DirectoryTaxonomyReader(taxoWriter), and pass both to the constructor that takes already opened readers (SearcherTaxonomyManager.java:127-135).

Trade-offs

Loses the replaceTaxonomy epoch check, since that constructor keeps no taxonomy writer (:154-155). Not run as a separate test.

Document a precondition instead

Add a NOTE like the Directory constructor's to the IndexWriter constructors.

Trade-offs

Contradicts the class javadoc's instruction to re-create the manager after replaceTaxonomy, and leaves the exception in place.

Downstream note

A read-only code search of the default branches found no use of SearcherTaxonomyManager or DirectoryTaxonomyReader in Elasticsearch, OpenSearch or Solr (2026-10-26, repeated 2026-10-30); use outside that search (plugins, forks, other branches) was not checked.

How it was found

The constructor's open order stood out while writing a TLA+ model of how the manager pairs its two readers. The model checker found a 5-state schedule that breaks the pair, and none with the index-first order. A public-API JUnit then reproduced it on the stock jars.

The method in full190 words

Method: TLA+ model checking (TLC), then an executed public-API test (the hunt classes it as semi-formal: the order was noticed while modelling, and the tool then produced the witness).

  • Model. TaxoPair.tla models the two writers, indexing (add a category, then a document), the constructor's two opens and the refresh. With the constructor as coded, TLC violates PairSafe (every ordinal the searcher's documents use is known to the paired taxonomy reader) in 5 states: Ctor1 (taxonomy snapshot), AddCat, AddDoc, Ctor2 (index snapshot). With the index-first order it is clean (28,053 and 3,172,539 distinct states in two sizes); with no indexing during construction it is clean too; a refresh-order mutant violates it, so the property is not vacuous.
  • Execution. A JUnit on the public API paused the constructor at getReader's first InfoStream message while another thread added a document with a new category; it failed on stock main with assertions on and off, 20 of 20 iterations, and passed with the swap. The minimal repro above is that test cut down to one thread and one claim, checked by two assertions (the searcher sees the document, and its category is known).

Prior reports, history and checks

Prior reports

None found: no issue, PR, list thread or downstream report describes the constructor's order (197 queries, 2026-10-26; refreshed 2026-10-30). The same exception is known only from readers that applications refreshed separately themselves (#6203, Won't Fix), where maintainers recommended this manager.

Searches4 searches
  • #6384 (LUCENE-5320) added the Directory constructor and its "unsync'd pair" NOTE, which covers only that constructor.
  • #6203 (LUCENE-5139, Won't Fix) and the java-user thread "Exception from FastTaxonomyFacetCounts" (2014-10): the same exception from readers the application opened and refreshed itself, separately rather than as one pair; maintainers recommended SearcherTaxonomyManager because it keeps the two in sync.
  • #4859 (LUCENE-3786) created the class; its 15 comments cover commit order and epochs, never the constructor's open order. #5951 (LUCENE-4886) states the reopen-index-first rule. #9505, #9291, #6499 and #14219 / PR #14443 are other changes to the class.
  • Not fixed on upstream main 716d6f216f52; no open PR touches the file (all 408 open PRs as of 2026-10-26, where only #16911 did, and the 52 updated since, checked on 2026-10-30). #16911 ("Decommission branch 10x"), which deleted every file on branch_10x, was merged there on 2026-10-29. Tracking issue #16937 does not list it. Registry rows LFV-250, LFV-264 and LFV-309 (same class) and LFV-312 (in its superclass, ReferenceManager) are other root causes.
  • Limits: keyword search; GitHub search reads the first results per query; private reports cannot be searched.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream)2026-10-2616 issue/PR, 3 commit and 2 downstream queries plus 64 GraphQL searches; the changed files of all 408 open PRs; 29 items read in fullrelated items only (#6384, #6203, #4859, #5951); none the same bug
Mailing lists (dev@, java-user@, issues@, commits@, builds@), JIRA archive2026-10-2680 list queries, 12 JIRA-archive and 5 JQL queriessame exception only from application-built pairs (2014); commit-order discussions (2016)
Web2026-10-2610 searchesno report
GitHub refresh2026-10-30issue and PR searches for SearcherTaxonomyManager sorted by creation; upstream main file history; tracking issue #16937nothing new; last change to the file 2025-05-18; not fixed

History

Present since the class was added (LUCENE-3786, 2013, first release 4.3.0) with the same order in every version of the file (17 commits on main; 16 distinct versions on all branches). Fails on 15 releases from 4.3.0 to 10.6.0 and on main; 4.3.0 with only the two opens swapped passes.

Versions tested7 rows

Introduced with the class by LUCENE-3786 (#4859), 03694e76930 on trunk and e9fb220401e on branch_4x, 2013-04-10 (SVN era, no pull request). The same commit gave refreshIfNeeded the index-first order and its first comment. The 3-argument constructor (LUCENE-6980, 5.5.0) and the RefreshCommitSupplier constructor (#14443, 10.3.0) inherit the body.

The history runs used the hunt's test or ports of it with the 4.x facet API; on 4.3.0 to 4.6.1 with -ea the counting code's own assertion fires (ord=3 vs maxOrd=3), elsewhere the ArrayIndexOutOfBoundsException. The minimal test itself was run on main, 10.6.0 and 10.5.2.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off; passes with the fix
10.6.0 (released 2026-10-29) affectedminimal test fails on the release jars, assertions on and off
10.5.2 affectedminimal test and the hunt's test fail, assertions on and off
10.0.0, 9.12.3, 9.0.0, 8.11.4, 8.11.2 affectedthe hunt's test or a port fails (ArrayIndexOutOfBoundsException)
7.7.3, 6.6.6, 5.5.5, 5.0.0, 4.10.4, 4.7.0 affectedport fails (ArrayIndexOutOfBoundsException)
4.6.1, 4.3.0 affectedport fails (with -ea the assertion ord=3 vs maxOrd=3; 4.3.0 with -da the exception); 4.3.0 with the two opens swapped passes
4.2.1 no such classSearcherTaxonomyManager does not exist (compile error)

Independent checks

Reproduced on stock main by an independent reproducer (its own test, the finder's seed), a devil's-advocate verifier, an adjudication and a credibility audit (each with new seeds); cross-model reviews (OpenAI GPT-5.6, reading only) rated it real; the fix passed every time.

Checks and verdicts305 words

Each check ran its own test or re-ran an earlier one on stock main; the controls (no indexing during construction, the index-first order) pass. The cross-model reviews were reading only.

Each independent check and its verdict
CheckVerdictWhat it found
TLA+ model (TLC) counterexamplePairSafe violated in 5 states; index-first constructor clean (28,053 and 3,172,539 distinct states).
Finder candidateLonger test fails -ea and -da, 20 of 20 iterations, and on 10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.2; plain threads 129 of 300 managers; fix passes, upstream TestSearcherTaxonomyManager 5 of 5.
Cross-model review (OpenAI GPT-5.6, reading only) real (high)Works-as-designed case weak; its one flaw (tied labels compared in tie order): the finder reports that it was fixed and the runs repeated. The refute-lens review gave no verdict.
Independent reproducer realOwn test with another pause point (the finder's seed DEADBEEF) fails on main, 10.5.2 and 9.12.3; hook-free 96 of 300 (main), 152 of 300 (10.5.2); no-indexing control passes; own fix passes.
Works-as-designed lens (OpenAI GPT-5.6, reading only) real (medium)Intended argument weak; the stage's own hook-free program: 62 and 77 of 300 on main, 71 of 300 on 10.5.2; control and fix 0 of 300.
Intended-behaviour devil's advocate realNo intended, misuse or reachability argument survives; re-ran with seed 1A2B3C4D on main and 10.5.2 (fail).
Adjudication confirmed (high)Re-ran the independent reproduction's and the finder's tests with seed 5EED1234 (fail; stress 178 and 164 of 300); the independent reproduction's fix check passes.
Registration check and credibility audit keepFresh seeds fail on main and 10.5.2 and pass with the fix; hook-free 78 of 300.
Minimal repro and this doc's runs still fails (2026-10-30)One-method test fails on main with assertions on and off (standalone and gradle), and on 10.6.0 and 10.5.2; passes with the fix.

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

Finding record

LFV-489, snapshot of 2026-11-06

Registry title
SearcherTaxonomyManager's IndexWriter (near-real-time) constructors open the taxonomy reader before the index reader, so a manager created while another thread indexes can hand out a first pair whose searcher holds documents with categories the paired taxonomy reader does not know (facet counting throws ArrayIndexOutOfBoundsException until the index changes and the manager refreshes)
Area
facet / taxonomy (SearcherTaxonomyManager NRT constructors: index and taxonomy reader open order)
Priority
Low, rank 610: A loud facet-counting ArrayIndexOutOfBoundsException from a fairly likely construction-time race, but it is transient and module-only.
Impact class
crash
Impact note
Applications on Lucene 4.3.0 through 10.6.0 or main that use taxonomy facets with SearcherTaxonomyManager's IndexWriter constructors (an opt-in facet module; default IndexWriterConfig, codec and searcher otherwise) and create the manager while another thread indexes documents that add new categories (for example indexing that starts before the search side, a manager re-created after replaceTaxonomy as the class javadoc requires, or one created lazily) can get a first pair on which facet counting throws ArrayIndexOutOfBoundsException and getOrdinal returns -1 for a visible document's category; it is query-time only, damages no index, and lasts until the first maybeRefresh() after the next index change (it persists while the index stays unchanged).
Summary
You index documents with taxonomy facets in one thread and meanwhile create a SearcherTaxonomyManager from the IndexWriter and DirectoryTaxonomyWriter. You expect its first SearcherAndTaxonomy, documented as 'a matched pair', to know every category its searcher's documents use, because TaxonomyReader's javadoc says to open the taxonomy after the main index and the manager's own refresh does exactly that. Instead the constructor opens the taxonomy reader first, so a document indexed in between is visible to the searcher while its category is missing from the taxonomy reader: getOrdinal returns -1, FastTaxonomyFacetCounts throws ArrayIndexOutOfBoundsException, and maybeRefresh() keeps the broken pair until the index changes again.
Introduced
03694e7693092432701be0186607e25e4633ea2b (trunk, svn r1466548; backported to branch_4x as e9fb220401ef, svn r1466550, the same day), 'LUCENE-3786: add SearcherTaxonomyManager' by mikemccand; JIRA LUCENE-3786, now #4859; no pull request (SVN era) [read: git blame and every revision of the file in the full-history clone; the class was added with the NRT constructor already opening new DirectoryTaxonomyReader(taxoWriter) before DirectoryReader.open(writer, applyAllDeletes), while the same commit gave refreshIfNeeded the index-first order with the comment 'Must re-open searcher first'; exec: 4.2.1 lacks the class, 4.3.0 fails]. Present since the class was created, not a regression (2013-04-10)
First affected version
4.3.0 [exec] (first release with SearcherTaxonomyManager; 4.2.1 does not have the class [exec: compile error, class absent from the 4.2.1 facet jar])
Affected versions
every release from 4.3.0 through 10.6.0, and main ea57360912ed and 645d4fe14a20 [exec: the same scenario fails on 4.3.0, 4.6.1, 4.7.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on 4.3.0, 9.12.3, 10.5.2 and main); 4.3.0 with only the two opens swapped passes; read: all 27 revisions of the file in the history clone, at both of its paths, open the taxonomy reader first]; the constructor taking a RefreshCommitSupplier (10.3.0, #14443) and the 3-argument constructor (5.5.0, LUCENE-6980) share the same body; not fixed upstream on 2026-10-26 (main, branch_10x, branch_10_5 and branch_10_6 read through the read-only contents API, last change to the file f3796ecf22b6, 2025-05-18); the minimal test fails on the 10.6.0 release jars (4ae946e5a9b9, released 2026-10-29) and on main 645d4fe14a20, -ea and -da [exec 2026-10-30], and the file is unchanged on upstream main through 2f424fd428da (2026-10-30) [read]. The Directory constructor has the same order but a javadoc NOTE warns that it can give an unsync'd pair, so it is not claimed
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, 197 recorded queries: three scripted prior-report searches (16 GitHub issue and PR queries, 3 commit queries, 2 downstream queries, 60 queries over the dev@, java-user@, issues@ and commits@ lists, 12 JIRA-archive queries), 64 GitHub GraphQL searches (48 on apache/lucene, 16 GitHub-wide or on downstream repositories), 20 more list queries over java-user@, dev@, issues@ and builds@, 5 JQL queries and 10 web searches; plus the changed-file lists of all 408 open apache/lucene PRs, every historical version of the file, the upstream branches, and full reads of 29 related issues and PRs; 29 hits assessed, none the same bug; the finder's scripted prior-report search and the independent reproduction's and the intended-behaviour check's searches found none either). Related, not this bug: #4859 (LUCENE-3786, created the class with this order; its 15 comments cover commit order and epochs, never the constructor's open order), #6203 (LUCENE-5139, Won't Fix: the same ArrayIndexOutOfBoundsException from an application's own SearcherManager and TaxonomyReader reopened in the wrong order; maintainers recommended SearcherTaxonomyManager), #6384 (LUCENE-5320, added the Directory constructor with its 'unsync'd pair' NOTE), the java-user threads 'Exception from FastTaxonomyFacetCounts' (2014-10, a maintainer: SearcherTaxonomyManager returns 'a sync'd IndexSearcher and DirectoryTaxonomyReader') and 'IndexWriter, DirectoryTaxonomyWriter & SearcherTaxonomyManager synchronization' (2016-09, commit order), #5951 (LUCENE-4886, states the reopen-index-first rule), #9505, #9291, #6499 and #14219 / PR #14443 (other changes to the class). No open PR changes SearcherTaxonomyManager.java (#16911 deletes every file from branch_10x). The Lucene.NET port has the same constructor order and no report. Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main 645d4fe14a20 (2026-10-28; upstream main 716d6f216f52, 2026-10-29, has no change to SearcherTaxonomyManager.java since f3796ecf22b6, 2025-05-18): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the index-first fix it passes, and so does the facet module's test suite. The minimal test also fails on the released 10.6.0 and 10.5.2 jars. Earlier hunt runs (the hunt on ea57360912ed, whose lucene/facet is identical): the hunt's longer test or ports of it fail on 14 releases from 4.3.0 to 10.5.2; 4.2.1 has no such class.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:34-38 (at ea57360912ed) (class javadoc: manages NRT reopen of both readers; a new manager is required after replaceTaxonomy), :44-50 (SearcherAndTaxonomy: 'a matched pair', 'Matching point-in-time DirectoryTaxonomyReader'), :57-62 (3-argument IndexWriter constructor, not Expert, no precondition), :81-103 (constructor body: :93 new DirectoryTaxonomyReader(taxoWriter), :96-97 DirectoryReader.open(writer, applyAllDeletes, false)), :105-125 (Directory constructor, NOTE :108-110 'unsync'd' pair), :127-158 (constructors taking already opened readers), :188-204 (refreshIfNeeded opens the index first; comments :189-191 and :200-202)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:46-55 (at ea57360912ed) ('it is important to open the taxonomy *after* opening the main index ... otherwise it would be possible for the main index to refer to a category which is not yet visible in the old snapshot of the taxonomy'), :57 (@lucene.experimental on TaxonomyReader)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:43-50, (at 645d4fe14a20) :57-103 (:93, :96-97), :105-111, :188-225 (:195-197, :204)
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:141-144 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-168 (at 645d4fe14a20)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:103-122, (at 645d4fe14a20) :134, :482; FastTaxonomyFacetCounts.java:57, :93, :97, :104, :110; directory/DirectoryTaxonomyReader.java:485-488
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:33-35, (at 645d4fe14a20) :46-55
  • lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:50-51, (at 645d4fe14a20) :116-119 (terms built without a taxonomy reader); DrillSideways.java:89-90, :157-166 (takes a TaxonomyReader and counts with FastTaxonomyFacetCounts over it)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:34-39 (at 645d4fe14a20)
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestSearcherTaxonomyManager.java:160, (at 645d4fe14a20) :192
Minimal repro
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:516-526 (at 645d4fe14a20)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:46-55, (at 645d4fe14a20) :57
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:34-39, (at 645d4fe14a20) :43-50, :57, :64-67, :105-111, :189-191, :200-202
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestSearcherTaxonomyManager.java:137-192 (at 645d4fe14a20)
  • lucene-history: git show 03694e76930 (SearcherTaxonomyManager.java: the :189-191 comment and the taxonomy-first constructor), git show f3796ecf22b6 (adds the :200-202 comment)
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/SearcherTaxonomyManager.java:93-98, (at 645d4fe14a20) :118-122, :127-135, :154-155
  • lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:243-266 (at 645d4fe14a20) (getSearcher decRefs the reader on throw, :262-264)
History
  • lucene-history: git log --follow (17 commits) and git log --all over both paths (16 distinct contents, every one opens the taxonomy first)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the fix diff, the minimal repro and an executed probe of the steps' scenario.

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.