All findings · Lucene finding · LFV-978 · replacetaxonomy-not-failure-atomic

DirectoryTaxonomyWriter.replaceTaxonomy is not failure-atomic: when it throws, close() commits an empty taxonomy and further use commits a broken one

If DirectoryTaxonomyWriter.replaceTaxonomy throws, say on a read error in the new taxonomy, it has already deleted the old one, yet the writer still believes the old categories exist. The usual close() then commits an empty taxonomy (3 categories become 0), and facet counts go silently wrong.

Summary

Priority
Low, rank 314Needs the rarely used experimental replaceTaxonomy to throw, followed by close() instead of rollback().
Impact
data corruption
Since
4.0.0-ALPHA (LUCENE-4108, 2012), when the method was addedintroduced 2012-06-06 · over 14 years
Affected
every release with the method, 4.0.0-ALPHA to 10.6.0, and main
Status
documented
Found by
a planning probe, then a JUnit and a failure-point enumeration

AI-assisted page: this finding was first signalled by code reading or testing 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

replaceTaxonomy deletes the old taxonomy first and copies the new one second. If the copy throws, the deletion is already pending, but the writer's ordinal counter and label cache still describe the old categories. Its next commit, for example by close(), saves the deletion, plus any labels added since at the wrong ordinals.

Background: how a taxonomy works

  1. Taxonomy facets keep a second index next to the main index. Each category, such as dim/a, is one document there, and its ordinal is its document number. The root category is always ordinal 0.
  2. For counting, main-index documents store these ordinals (they also index the category paths, which drill-down uses). Facet counting adds up ordinals and asks the taxonomy for the label of each, so the two indexes must stay in step.
  3. DirectoryTaxonomyWriter keeps a counter of the next ordinal and a cache from labels to ordinals, so addCategory rarely has to search its index. replaceTaxonomy swaps in another taxonomy, typically a rebuilt one, together with IndexWriter#addIndexes on the main index.
Summary card for DirectoryTaxonomyWriter.replaceTaxonomy: it deletes the committed taxonomy, then the copy of the new one throws an IOException; the writer still counts the old categories and returns their old ordinals, close() commits an empty taxonomy with no root, and facet counts over the main index come out wrong; the fix rolls the writer back to its last commit and closes it.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Commit a taxonomy with one category: root = 0, dim = 1, dim/a = 2, so 3 categories.
  2. Call replaceTaxonomy(source) with a valid, closed taxonomy whose files fail to read. It throws IOException (here on _0.si), and try-with-resources calls close(). You expect the last commit to stay: 3 categories, dim/a at 2.
  3. Instead the committed taxonomy has 0 categories, not even the root, and dim/a is -1. The method had run deleteAll() before the copy failed; the writer still counted 3 categories while an NRT reader on it saw 0, and close() committed the deletion.
  4. Keep using the writer instead and it hands out stale ordinals: addCategory(dim/a) returns 2, which the commit no longer has, and a new dim/c gets 3 but is stored at ordinal 0, the root's slot.
  5. The main index still carries the old ordinals, so later sessions count old documents under new labels, with no error: a=2 b=1 x=1 y=1 comes out as x=3 y=2.

Who is affected

Applications that use taxonomy facets (the opt-in facet module) and call replaceTaxonomy, typically to swap in a rebuilt index and taxonomy. One non-fatal exception from that call, then the usual close(), durably destroys the taxonomy; facet counts are silently wrong afterwards. Deterministic, any size. Rare: the method is seldom used, and rollback() after the exception avoids it.

Mechanism, terms and numbers639 words

DirectoryTaxonomyWriter#replaceTaxonomy(Directory) swaps a writer's taxonomy for another one, usually together with IndexWriter#addIndexes on the main index. It calls indexWriter.deleteAll() and then indexWriter.addIndexes(taxoDir), and resets the writer's ordinal counter, label cache, parent arrays and epoch only after both succeed (DirectoryTaxonomyWriter.java:872-891 on main 645d4fe14a20), with no failure handling. When addIndexes throws an exception that is not an Error (an IOException while the source is read, a source with no index, a source still locked by its writer), the internal index is emptied, pending the next commit, while the writer still describes the old taxonomy. close() then commits a taxonomy with no categories at all; continued use returns old ordinals and stores new labels at the wrong ordinals, including the root's slot 0; and facet counts over the main index, which still carries the old ordinals, come out wrong with no error. It has been there since the method was added in 4.0.0-ALPHA (2012). rollback() right after the exception keeps the last commit.

The bug

  1. replaceTaxonomy calls indexWriter.deleteAll() (DirectoryTaxonomyWriter.java:874), then indexWriter.addIndexes(taxoDir) (:875), and only then refreshes its reader and resets nextID, the parent arrays, the cache and the epoch (:876-890). There is no ensureOpen() and no try/catch.
  2. addIndexes is all-or-nothing by itself (IndexWriter.java:3084-3086), but the deleteAll() before it is a separate pending change. For a failure inside its try (IndexWriter.java:3114), such as a read error, addIndexes treats only an Error as tragic (IndexWriter.java:3212-3220), so after an IOException the internal writer stays open with every segment about to be dropped; a lock failure on the source is thrown earlier (:3108) and leaves it open as well.
  3. The taxonomy writer stays open too. getSize() returns the stale counter (:575-578); addCategory answers from the stale cache first (:385; the default LRU cache still holds the old labels of this small taxonomy); a new label gets the ordinal nextID.getAndIncrement() (:468-469) but lands at document 0 of the emptied index, the root's place.
  4. close() "commits whatever changes made to it" (:283-294): the deletion, plus whatever was added since.

The numbers

Committed taxonomy: root = 0, dim = 1, dim/a = 2. replaceTaxonomy with a valid, closed taxonomy whose _0.si cannot be read throws IOException. Right after it, writer.getSize() is 3 and an NRT reader on the writer sees 0. close() commits size 0; dim/a is -1. Kept open instead: addCategory(dim/a) returns 2, addCategory(dim/c) returns 3, getSize() 4; after close() the commit has size 1, with dim/c at ordinal 0. Over a main index with documents dim/a, dim/b, dim/a, a failed replace and a later session that adds dim/x and dim/y give counts x=3 y=2 instead of a=2 b=1 x=1 y=1: the reopened taxonomy is empty, the next writer adds a root at 0 (:181-185) and gives the old documents' ordinals 2 and 3 to dim/x and dim/y.

When it happens and when not

  • Triggers: an I/O error while the source is read or copied (a valid source; the IOException the method declares); a source directory with no index (IndexNotFoundException, for example a rebuilt taxonomy that was never committed); from 4.7.0, a source whose own writer is still open (LockObtainFailedException). The last two are rejected only after deleteAll() ran.
  • Every fault point: failing each of the 36 directory calls of a successful replace in turn breaks the taxonomy under continued use at all 36, and under close() alone at the first 32 (the last 4 fail in the reader refresh after the copy completed, so close() commits the new taxonomy).
  • Not affected: a replace that succeeds; addTaxonomy (safe under all 19 single faults); a caller that calls rollback() right after the exception, which keeps the last commit.
Exposure and likelihood181 words
  • Who is hit. Users of DirectoryTaxonomyWriter who call replaceTaxonomy, as its javadoc suggests, "in conjunction with IndexWriter#addIndexes(Directory...) to replace both the taxonomy and the search index content". No special configuration: the plain DirectoryTaxonomyWriter(Directory) constructor with the writer's own default configuration; the Directory is the caller's choice.
  • What triggers it. An I/O error while the source is read or copied, a wrong or uncommitted source directory, or (since 4.7.0) a source whose writer is still open.
  • Effect. Data corruption that is committed and survives reopen: an empty taxonomy with no root after close(), or one with labels at the wrong ordinals after continued use. Main-index documents that refer to the old ordinals then get no label or another category's label, so facet counts are wrong without any error. Repair needs a taxonomy backup or a re-index of both indexes (inferred, not executed).
  • Scale. Deterministic, single-threaded, any size: the repro has 3 categories.
  • How likely: rare. It needs the rarely used method and an error path. The caller does see the exception, and rollback() right after it keeps the last commit.

Minimal repro

The test shows that a DirectoryTaxonomyWriter.replaceTaxonomy call that throws an IOException while reading a valid, closed source taxonomy has already emptied the taxonomy (deleteAll ran first), while the writer keeps answering from its old cache, and close() commits that emptied index.

TestReplaceTaxonomyFailure.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.AlreadyClosedException;
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 TestReplaceTaxonomyFailure extends LuceneTestCase {

  public void testAddCategoryAfterReplaceTaxonomyReadFailure() throws IOException {
    FacetLabel label = new FacetLabel("dim", "a");
    try (Directory taxoDir = new ByteBuffersDirectory();
        Directory source = new ByteBuffersDirectory()) {
      new DirectoryTaxonomyWriter(source).close();
      Directory unreadableSource =
          new FilterDirectory(source) {
            @Override
            public IndexInput openInput(String name, IOContext context) throws IOException {
              if (name.startsWith("segments")) {
                return super.openInput(name, context);
              }
              throw new IOException("simulated read failure on " + name);
            }
          };
      DirectoryTaxonomyWriter writer = new DirectoryTaxonomyWriter(taxoDir);
      int ordinal = writer.addCategory(label);
      writer.commit();
      expectThrows(IOException.class, () -> writer.replaceTaxonomy(unreadableSource));
      try {
        ordinal = writer.addCategory(label);
      } catch (AlreadyClosedException writerClosedItselfOnFailure) {
      }
      writer.close();
      try (DirectoryTaxonomyReader reader = new DirectoryTaxonomyReader(taxoDir)) {
        // expected: dim/a at the ordinal the writer returned (2); actual: -1, not in the commit
        assertEquals("committed ordinal of dim/a", ordinal, reader.getOrdinal(label));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-978/, 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.TestReplaceTaxonomyFailure
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected: addCategory(dim/a) after the failure returns dim/a's existing ordinal (2) and the commit still has dim/a there (a writer that closes itself on the failure is also accepted). Actual: addCategory returns 2, but a reader on the commit gives -1 for dim/a.

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

More files, environment, full output37 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: committed ordinal of dim/a expected:<2> but was:<-1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.facet.taxonomy.directory.TestReplaceTaxonomyFailure.testAddCategoryAfterReplaceTaxonomyReadFailure(TestReplaceTaxonomyFailure.java:57)

Why it looks unintended

  • After the throw the open writer breaks its own contracts: addCategory must return an existing category's ordinal, and the root "always has the ordinal 0".
  • The trigger is the declared IOException on a valid source; IndexWriter stays consistent after deleteAll() plus a failed addIndexes (no-index source).
  • Stale writer state after replaceTaxonomy was fixed as a bug twice (LUCENE-4633, LUCENE-5242).
The full argument240 words
  • Contracts. TaxonomyWriter.java:52-53: addCategory "returns its ordinal. If the category was already present in the taxonomy, its existing ordinal is returned." After the failure it returns 2 for dim/a, which the next commit does not have. TaxonomyReader.java:87-92: "The root category (the category with the empty path) always has the ordinal 0"; the writer's constructor says the same (DirectoryTaxonomyWriter.java:183-185). After the failure a new label is stored at ordinal 0. As Shai Erera put it on #5949 (LUCENE-4884), the taxonomy index "has the ROOT document, at doc=0". (#12337 later added a package-private deleteAll() for reindexWithNewOrdinalData, which re-adds every category, root first, before it commits; replaceTaxonomy is unchanged.)
  • A legitimate trigger. A read error on a valid, closed source raises the IOException that replaceTaxonomy declares. After IndexWriter#deleteAll() and an addIndexes that fails on a source with no index (control C2), IndexWriter's maxDoc is 0, matching its pending state; the taxonomy writer instead reports getSize() 3 while an NRT reader on it sees 0.
  • No documented escape hatch. The replaceTaxonomy javadoc (:867-871) says nothing about failure, and nothing says the writer must be rolled back or discarded after one.
  • History. LUCENE-4633 (#5698: the reader was not refreshed) and LUCENE-5242 (#6306: the parent arrays were not reset) fixed stale writer state after a successful replace as bugs, with tests (TestDirectoryTaxonomyWriter.java:400-404, :534-553). The failure path was never discussed (LUCENE-4108, #5180) and has no test.
The devil's-advocate check213 words

The reviews by another model family (OpenAI GPT-5.6) rated this case moderate (the intended-behaviour checker, who built it in full, gave medium confidence for the close()-only part, and the credibility audit pushes back on that part as well): the method was modelled on IndexWriter#deleteAll() + addIndexes() (LUCENE-4108), and IndexWriter's own deleteAll(), failed addIndexes() and close() also commit an empty index; close() is documented to commit "whatever changes made to it"; deleteAll() "can be rolled back using rollback()" (IndexWriter.java:2732-2733) and TwoPhaseCommit#rollback() "Discards any changes that have occurred since the last commit"; a caller who calls rollback() keeps the last commit; two triggers (no index, a locked source) are inputs the method should reject; the class is @lucene.experimental.

Why it fails: the caller made one call, not two, and after a declared, non-tragic exception that call left the writer contradicting its own index, which IndexWriter does not do in the same situation (control C2: deleteAll() + a failed addIndexes leaves its maxDoc and its pending state in step); the committed result is not a valid taxonomy (no root), unlike IndexWriter's empty index; nothing documents rollback() as required; the invalid inputs are rejected only after the deletion. Verdict: a defect. What survives: the close() outcome alone resembles IndexWriter semantics.

Suggested fix

Recommended Roll back and close on failure; make rollback() safe to repeat

When replaceTaxonomy throws, roll the writer back to its last commit and close it, then rethrow; and make rollback() a no-op on a closed writer, so callers that already call it after a failure keep working. Validated: the repro passes, the whole facet module passes (307 tests) and the module's static checks pass.

public synchronized void replaceTaxonomy(Directory taxoDir) throws IOException {
  ensureOpen();
  try {
    doReplaceTaxonomy(taxoDir); // the old body, unchanged
  } catch (Throwable t) {
    try {
      rollback(); // back to the last commit, and close
    } catch (Throwable t2) {
      t.addSuppressed(t2);
    }
    throw t;
  }
}
All options and trade-offs3 options · 647 words

One fix was built and validated; two alternatives were not built.

Roll back and close on failure; make rollback() safe to repeat (validated by the checks) (recommended)

A change in DirectoryTaxonomyWriter (1 file, +24/-4; tidy leaves it unchanged). The old body of replaceTaxonomy moves to a private doReplaceTaxonomy; replaceTaxonomy adds ensureOpen() and, on any Throwable, calls rollback() (failures there are added as suppressed) and rethrows. Its javadoc gains: "If this method throws an exception, this writer is rolled back to its last commit and closed, as by rollback()."

rollback() drops its ensureOpen() and does nothing on a closed writer, like close() (DirectoryTaxonomyWriter.java:289) and IndexWriter#rollback() ("this acts like close() in closeable", IndexWriter.java:2576-2577). Without that, the fix would break today's workaround: code that calls rollback() in its catch block would get AlreadyClosedException, as a run of the probe with the plain rollback-on-failure sketch shows.

public synchronized void replaceTaxonomy(Directory taxoDir) throws IOException {
  ensureOpen();
  try {
    doReplaceTaxonomy(taxoDir); // the old body, unchanged
  } catch (Throwable t) {
    try {
      rollback(); // back to the last commit, and close
    } catch (Throwable t2) {
      t.addSuppressed(t2);
    }
    throw t;
  }
}

Trade-offs

  • A failure inside replaceTaxonomy rolls the writer back and closes it, like a tragic event in IndexWriter: the caller must open a new one. That held on every tested failure path; if the rollback itself throws, that failure is added as suppressed and the writer may stay open, a corner for maintainers to review.
  • Categories added but not committed before the call are dropped with the rollback.
  • Code that kept using the writer after a failure now gets AlreadyClosedException instead of wrong ordinals.
  • rollback() on a closed writer no longer throws; no upstream test expects it to (testRollback passes).
  • It protects the taxonomy writer only: replacing the taxonomy and the main index together stays a two-writer operation for the application (DirectoryTaxonomyWriter.java:867-871, TaxonomyWriter.java:36-45).

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, OpenJDK 25: the repro test fails on the stock tree and passes with the fix, assertions on and off.
  • With the fix and the test, the whole lucene/facet module passes with assertions on and off: 40 suites (39 upstream + the test), 307 tests, 0 failures; TestDirectoryTaxonomyWriter (15 tests) among them.
  • :lucene:facet:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.
  • Standalone runner: the finder's 4 tests, the independent reproduction's 5 tests and 4 controls pass; the intended-behaviour check's controls C1 and C2 pass, and C3, which asserts the stock bug state, fails by design with AlreadyClosedException; the failure-point enumeration finds no failing fault point for replaceTaxonomy (all 76 cases keep the last commit or refuse further use; stock: 32 and 36 of 36 fault points fail); its 10 remaining violations of 669 cases are all in the sibling reindexWithNewOrdinalData, another method.
  • Not run: nightly tests, a benchmark (the change is off the hot path).

Validate the source before deleting (not built)

Check that the source holds an index, that its write lock can be taken and that its segment metadata reads, and only then call deleteAll().

Trade-offs

Keeps the writer usable for bad inputs, but an I/O error later in the copy still leaves the mismatch: on stock code a fault at any of the 32 directory calls inside addIndexes breaks the commit, and a pre-check runs before them. A useful addition to the recommended fix, not a replacement. (A check that reads the source's segment metadata would happen to make the repro pass, since its read error is on _0.si.)

Document rollback() after a failure (not built)

A javadoc sentence on replaceTaxonomy: if it throws, call rollback().

Trade-offs

Cheap, but every try-with-resources caller who misses the note still commits an empty taxonomy. Resynchronising the writer from the emptied index instead was considered and not chosen: close() would still commit the deletion.

Downstream note

Until a fix ships, applications that call replaceTaxonomy should call rollback() on the taxonomy writer when it throws, instead of close(): that keeps the last commit.

How it was found

A planning probe for exception safety saw a failed replaceTaxonomy empty the committed taxonomy. The hunt reduced it to a public-API JUnit, then failed each of the 36 directory calls of a successful replace in turn on the stock jar: every one left the writer out of step with its index. Independent checks then reproduced it.

The method in full205 words

Method: non-formal first signal, then a bounded-exhaustive failure-point enumeration (the hunt's hunt unit for failure atomicity of taxonomy replace and reindex, 2026-10-28, main a9779ef3c193).

  • Lead. A planning probe on the stock jars (main, 10.5.2, 9.12.3, the 10.6 RC): after replaceTaxonomy(emptyDir) threw IndexNotFoundException the writer still reported size 4, returned old ordinals, and close() committed a taxonomy of size 1 with the new label at ordinal 0; an injected read failure on _0.si led to a committed size of 0.
  • JUnit. Reduced to a public-API JUnit with three outcomes (close(), continued use, facet counts over a main index) and the read-error trigger on a valid, closed source.
  • Enumeration. Each directory call of a successful replace (36) was failed in turn, under two follow-ups (close() only; continued use, then close()), plus the no-index and locked-source triggers, against the invariant "the writer's answers equal the commit, which holds the old or the new taxonomy, with the root at 0". On the stock jar every fault violated it under continued use, and all but the 4 faults after the copy (in the reader refresh) under close() only; with the fix sketch none did. The same run found addTaxonomy safe under all 19 single faults.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-29, refreshed the same day): 132 queries over GitHub, commits, downstream trackers, the mailing lists, the JIRA archive and the web, plus the changed files of all 414 open Lucene PRs. The nearest issues are two fixed success-path bugs in the same method.

Searches4 searches

Related, not reports of this bug: #6306 (LUCENE-5242) and #5698 (LUCENE-4633), stale writer state after a successful replaceTaxonomy, fixed in 2013 and 2012 (this bug reproduces on code with both fixes); #5180 (LUCENE-4108), which introduced the method without discussing failure; #5949 (LUCENE-4884, open), where Shai Erera describes the root document at doc 0 and suggests resetting a taxonomy with replaceTaxonomy from an empty, closed taxonomy; #4859, #6203 and #4515 on NRT and epoch design around a successful replace. The open PRs #16847 (a draft) and #13951 touch the file with casts only, and #12862 with an import change only.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits and downstream trackers2026-10-29a scripted prior-report search, two runs (52 and 45 queries on the defect, its symptoms and error messages); 23 manual searches over apache/lucene, Elasticsearch, OpenSearch, Solr and Lucene.NETno report; replaceTaxonomy finds 7 issues and 0 PRs, none about a failing replace
Open PRs2026-10-29the changed files of all 414 open apache/lucene PRs; every issue and PR updated since 2026-10-24only #16847 and #13951 (casts) and #12862 (an import change, found when the saved file lists were re-read) touch the file
JIRA archive and mailing lists2026-10-29JQL on all projects and LUCENENET; dev@, java-user@, issues@ and commits@only the same 7 issues and their notifications
GitHub (re-check)2026-10-29search replaceTaxonomy in apache/lucene issues and PRs; PRs naming DirectoryTaxonomyWriter updated since 2026-10-28; the file's latest upstream commits; the main headnothing new; the file unchanged since f339e24e8ed6 (2024); upstream main 645d4fe14a20

History

In the method since it was added: LUCENE-4108 (8b5af86c45bb, 2012), first released in 4.0.0-ALPHA. Later changes fixed the success path only. Every executed release with the method fails; 3.6.x does not have it.

Versions tested7 rows

Introduced by 8b5af86c45bb on trunk, "LUCENE-4108: add replaceTaxonomy to DirectoryTaxonomyWriter" (Shai Erera, 2012-06-06; migrated as #5180, an SVN-era patch), released through the branch_4x port 806e16859e4a in 4.0.0-ALPHA (2012-06-29); first GA release 4.0.0. git blame of the deleteAll()/addIndexes() lines still points at it. Not a regression.

Later changes fixed the success path or the formatting only: LUCENE-4061, LUCENE-4633 (4.1.0), LUCENE-5242 (4.5.1), LUCENE-8609, LUCENE-9570, #11775; the last upstream commit to the file, #12337 (f339e24e8ed6, 2024-02-08), left the method unchanged. The locked-source trigger exists from 4.7.0 (LUCENE-5228 made addIndexes(Directory...) take the source's write lock). Releases between the executed ones are covered by reading the unchanged method.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe repro test fails (gradle and the standalone runner, assertions on and off)
10.5.2, 9.12.3 affectedthe repro test fails (assertions off)
main a9779ef3c193, branch_10_6 4ae946e5a9b9 affecteda release-portable check fails with all three triggers; the earlier JUnits fail
4.7.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 affectedthe check fails with all three triggers; a successful replace works
4.0.0-ALPHA, 4.0.0, 4.6.1 two triggersfails with the no-index and I/O-error triggers; a locked source is not rejected before 4.7.0
3.6.0, 3.6.2 no APIno replaceTaxonomy (compile error)
main + fix not affectedthe repro test passes; the facet module (307 tests) passes, assertions on and off

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by another model family (OpenAI GPT-5.6) and a credibility audit agree on the root cause; the adjudication re-ran the independent tests and confirmed it with high confidence. This doc re-ran the tests and the enumeration on 645d4fe14a20 and validated the fix with gradle.

Checks and verdicts286 words

The cross-model reviews were reading-only: they could not run Java. The refute-lens review returned a procedural "not real" while agreeing on the root cause; a separate re-run failed 4 of 4. An executing cross-model review is still open.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate4-test JUnit fails 4 of 4 on main (-ea, -da), 40 of 40 over 10 iterations; main() fails on 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and the 10.6 RC; failure-point enumeration over 36 fault points.
cross-model review, refute lens not real, medium (procedural)OpenAI GPT-5.6, reading-only; agreed on the root cause, asked for a valid-source trigger and a success control. A separate run re-ran the JUnit: 4 of 4 fail.
Independent reproducer realOwn 5-test JUnit over three triggers fails 5 of 5 on main, 10.5.2 and 9.12.3; its 4 controls pass; its fix passes 9 of 9.
Cross-model review, intended lens real, mediumOpenAI GPT-5.6, reading-only; rated the "call rollback()" argument moderate and outweighed.
Intended-behaviour devil's advocate real (medium-high)Built the strongest intended case; controls C1-C3 (rollback keeps the commit, the IndexWriter analogue stays consistent, getSize 4 vs NRT reader 0) pass.
adjudication confirmed, highRe-ran the independent reproduction's JUnit with a new seed: 5 of 5 fail with assertions on and off.
Registration check and credibility audit keepThe finder's JUnit fails 4 of 4 (two seeds) and passes with the fix; the independent reproduction's controls pass.
this doc as aboveThe one-test repro on 645d4fe14a20 (gradle and standalone), 10.5.2 and 9.12.3; the earlier tests, the probe and the enumeration on 645d4fe14a20; the fix with the facet module, tidy and check.

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

Finding record

LFV-978, snapshot of 2026-11-06

Registry title
DirectoryTaxonomyWriter.replaceTaxonomy is not failure-atomic: if addIndexes throws, the taxonomy is already emptied but the writer keeps its old ordinal counter and cache, so later addCategory calls and the next commit or close() store an empty or broken taxonomy
Area
facet / taxonomy writer (DirectoryTaxonomyWriter.replaceTaxonomy failure path: deleteAll before addIndexes, writer state reset only on success)
Priority
Low, rank 314: Needs the rarely used experimental replaceTaxonomy to throw, followed by close() instead of rollback().
Impact class
data corruption
Impact note
Applications that use the taxonomy facet module with the default DirectoryTaxonomyWriter (default IndexWriterConfig, codec and Directory) and call the rarely used, @lucene.experimental replaceTaxonomy are hit when that call throws a non-fatal exception (an I/O error while a valid, closed source is read or copied; a source directory that holds no index; since 4.7.0 a source still locked by its own writer) and they then close() the writer, as try-with-resources does, or keep using it instead of calling rollback(): close() commits an empty taxonomy with no root, continued use commits a structurally broken one (old ordinals returned for labels the commit no longer has, a new label stored at the root's slot), and main-index documents that still carry the old ordinals then get no labels or other categories' labels, so facet counts are wrong with no error; deterministic, single-threaded, at any size; the caller does see the exception first, and rollback() right after it keeps the last commit.
Summary
You call replaceTaxonomy on a DirectoryTaxonomyWriter to swap in a rebuilt taxonomy, the call throws (in the repro an IOException while a file of a valid, closed source is read, or IndexNotFoundException because the source holds no index), and the application then closes the writer as usual or keeps using it. You expect the failed call to leave the taxonomy that the writer and its last commit describe, so that addCategory returns 'its existing ordinal' for an existing category (TaxonomyWriter.java:53) and the root keeps ordinal 0. Instead replaceTaxonomy has already emptied the internal index with deleteAll() while the writer keeps its old ordinal counter, cache and parent arrays: close() commits a taxonomy of size 0 (expected 4), a kept-open writer returns ordinal 2 for dim/a although the commit no longer has it and stores a new label with ordinal 4 at docID 0, and over a main index that still refers to the old ordinals the facet counts a=2 b=1 x=1 y=1 come out as x=3 y=2.
Introduced
8b5af86c45bb0caaf82a26b34f0dc1af21d97fde (trunk, svn r1346784) and its branch_4x port 806e16859e4ade83eea9e9d0cfba79fb50877180 (svn r1346793, the commit that shipped in 4.0.0-ALPHA), LUCENE-4108 'Add replaceTaxonomy to DirectoryTaxonomyWriter' (now #5180, by shaie; an SVN-era patch, no PR) [read: git blame -w -C -M of DirectoryTaxonomyWriter.java:874-875 (indexWriter.deleteAll(); indexWriter.addIndexes(taxoDir)) gives 8b5af86c45bb; git log -L of the method shows later changes only to the success path or to formatting (LUCENE-4061, LUCENE-4633, LUCENE-5242, LUCENE-8609, LUCENE-9570, #11775) and no failure handling ever added] [exec: 3.6.0 and 3.6.2 have no replaceTaxonomy (compile error, the only error); 4.0.0-ALPHA fails] (2012-06-06 (author date = commit date for both commits: 08:46:10 UTC trunk, 09:05:02 UTC branch_4x; issue opened 2012-06-04, resolved 2012-06-18))
First affected version
4.0.0-ALPHA (2012-06-29) [exec], the first release with the API; first GA release 4.0.0 (2012-10-11) [exec]; 3.6.0 and 3.6.2 have no replaceTaxonomy [exec: compile error] (the author declined a 3.6.1 backport, #5180). The locked-source trigger exists only from 4.7.0 (LUCENE-5228, d3416c198a1, made addIndexes(Directory...) take the source's write lock; before it such a replace succeeds) [exec: 4.0.0-ALPHA, 4.0.0, 4.6.1]
Affected versions
every release from 4.0.0-ALPHA to 10.6.0 (branch_10_6 head 4ae946e5a9b9) and main a9779ef3c193; never fixed [exec: a release-portable main() (one variant per label API generation) fails on 4.0.0-ALPHA, 4.0.0 and 4.6.1 (3 of 5 cases: the no-index and I/O-error triggers; the locked source is not rejected before 4.7.0) and on 4.7.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2, branch_10_6 and main (4 of 5 cases), and its success control passes everywhere the API exists; the finder's earlier main() also fails on 8.0.0 and 9.0.0; the finder's JUnit fails 4 of 4 on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 5 on main (-ea and -da), 10.5.2 and 9.12.3] [read: the releases in between, from git log -L of replaceTaxonomy: the deleteAll-then-addIndexes order and the missing failure handling never changed; DirectoryTaxonomyWriter.java last changed upstream in f339e24e8e (#12337, authored 2024-02-08), which left replaceTaxonomy's body unchanged, and is the same at ea57360912ed and a9779ef3c193]. Executed only on aarch64 Linux with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-29, read-only; the prior-report record, 132 queries, plus the finder's scripted prior-report search at 2026-10-28 (51 queries), the independent reproduction's, the intended-behaviour check's and the adjudication's searches, and a recheck): two scripted prior-report searches over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 23 manual GitHub searches (apache/lucene and downstream projects incl. Lucene.NET; 'replaceTaxonomy' finds the same 7 issues and 0 PRs, none updated after 2022); the changed files of all 414 open apache/lucene PRs (only #16847, an int cast in replaceTaxonomy, and #13951, a cast elsewhere in the file, touch it; neither adds failure handling); every issue and PR updated since 2026-10-24 and the 30 newest PRs (#16893-#16936, created 2026-10-22 to 2026-10-28; none touches lucene/facet); and the upstream commits API for DirectoryTaxonomyWriter.java on main, branch_10x and branch_10_6, compared by author date (last change f339e24e8e, authored and committed 2024-02-08, replaceTaxonomy unchanged). Related, not this bug: #5180 / LUCENE-4108 (introduced the method, no failure discussion), #6306 / LUCENE-5242 and #5698 / LUCENE-4633 (success-path state resets in the same method, fixed as bugs in 2013 and 2012; this bug reproduces on code with both fixes), #5949 / LUCENE-4884 (open: a taxonomy deleteAll leaves files; shaie cites the root document at doc 0 as the reason a writer deleteAll 'makes no sense'), #4859 / LUCENE-3786, #6203 / LUCENE-5139 and #4515 / LUCENE-3441 (NRT, epoch and reopen-order design around a successful replace), #4653 / LUCENE-3579 (ensureOpen added to the other public methods in 2011, before replaceTaxonomy existed), LUCENE-702, #6441 / LUCENE-5377 and #9738 / LUCENE-8692 (IndexWriter-level addIndexes transactionality and tragic-exception classification, a different layer). Tracking issue #16937 does not mention it
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; runs of 2026-10-29) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (assertions off). The hunt's earlier runs used main a9779ef3c193 (lucene/facet and IndexWriter.java are identical), branch_10_6 4ae946e5a9b9 and the releases 3.6.0 to 10.5.2 listed under history.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:76 (at a9779ef3c193) (@lucene.experimental), :183-185 (constructor: 'Make sure that the taxonomy always contain the root category with category id 0'), :283-294 (close() javadoc 'closes the underlying IndexWriter, which commits whatever changes made to it'; commit() at :290), :855-865 (rollback() javadoc: 'Rollback changes to the taxonomy writer and closes the instance'; body rolls back the internal IndexWriter and closes the writer), :867-891 (replaceTaxonomy: javadoc :867-871 'Replaces the current taxonomy with the given one'; no ensureOpen; deleteAll :874, addIndexes :875, reader refresh :876-878, nextID :879, taxoArrays :880, cache reset :884-887, ++indexEpoch :890; no try/catch); identical lines at ea57360912ed
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:53 (at a9779ef3c193) ('If the category was already present in the taxonomy, its existing ordinal is returned.'); lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:88 ('The root category (the category with the empty path) always has the ordinal 0')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3084-3086 (at a9779ef3c193) (addIndexes: 'if an Exception occurs (for example disk full), then either no indexes will have been added or they all will have been'), :3103-3225 (addIndexes(Directory...) takes the source write lock; only an Error is tragic)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:181-185 (at 645d4fe14a20) (root at ordinal 0), :283-294 (close commits), :385 (addCategory reads the cache first), :468-469 (addDocument, nextID.getAndIncrement), :575-578 (getSize returns nextID), :867-891 (replaceTaxonomy: deleteAll :874, addIndexes :875, resets :876-890)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3084-3086 (at 645d4fe14a20) (addIndexes transactional), :3108 (source write locks taken before the try), :3114 (the try), :3212-3220 (only an Error is tragic)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:867-871 (at 645d4fe14a20) (replaceTaxonomy javadoc), :76 (@lucene.experimental)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:52-53; (at 645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:87-92; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:76, :183-185, :283-294, :867-871
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2728-2734 (at 645d4fe14a20) (deleteAll can be rolled back), :3084-3086, :3108, :3212-3220; lucene/core/src/java/org/apache/lucene/index/TwoPhaseCommit.java:44-48
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyWriter.java:383-421 (at 645d4fe14a20) (LUCENE-4633 comment :400), :534-553 (added by 84f6e500b35, LUCENE-5242)
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/DirectoryTaxonomyWriter.java:289, (at 645d4fe14a20) :855-865, :867-871; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyWriter.java:36-45; lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2576-2577; lucene/facet/src/test/org/apache/lucene/facet/taxonomy/directory/TestDirectoryTaxonomyWriter.java:165-180

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

Repro files

About the diagrams

It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe.

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.