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.
- 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. - 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.
DirectoryTaxonomyWriterkeeps a counter of the next ordinal and a cache from labels to ordinals, soaddCategoryrarely has to search its index.replaceTaxonomyswaps in another taxonomy, typically a rebuilt one, together withIndexWriter#addIndexeson the main index.
- Commit a taxonomy with one category: root = 0,
dim= 1,dim/a= 2, so 3 categories. - Call
replaceTaxonomy(source)with a valid, closed taxonomy whose files fail to read. It throwsIOException(here on_0.si), and try-with-resources callsclose(). You expect the last commit to stay: 3 categories,dim/aat 2. - Instead the committed taxonomy has 0 categories, not even the root, and
dim/ais -1. The method had rundeleteAll()before the copy failed; the writer still counted 3 categories while an NRT reader on it saw 0, andclose()committed the deletion. - Keep using the writer instead and it hands out stale ordinals:
addCategory(dim/a)returns 2, which the commit no longer has, and a newdim/cgets 3 but is stored at ordinal 0, the root's slot. - 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=1comes out asx=3 y=2.
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.
replaceTaxonomycallsindexWriter.deleteAll()(DirectoryTaxonomyWriter.java:874), thenindexWriter.addIndexes(taxoDir)(:875), and only then refreshes its reader and resetsnextID, the parent arrays, the cache and the epoch (:876-890). There is noensureOpen()and notry/catch.addIndexesis all-or-nothing by itself (IndexWriter.java:3084-3086), but thedeleteAll()before it is a separate pending change. For a failure inside itstry(IndexWriter.java:3114), such as a read error,addIndexestreats only anErroras tragic (IndexWriter.java:3212-3220), so after anIOExceptionthe 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.- The taxonomy writer stays open too.
getSize()returns the stale counter (:575-578);addCategoryanswers from the stale cache first (:385; the default LRU cache still holds the old labels of this small taxonomy); a new label gets the ordinalnextID.getAndIncrement()(:468-469) but lands at document 0 of the emptied index, the root's place. close()"commits whatever changes made to it" (:283-294): the deletion, plus whatever was added since.
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.
- Triggers: an I/O error while the source is read or copied (a valid source; the
IOExceptionthe 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 afterdeleteAll()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, soclose()commits the new taxonomy). - Not affected: a replace that succeeds;
addTaxonomy(safe under all 19 single faults); a caller that callsrollback()right after the exception, which keeps the last commit.
Exposure and likelihood181 words
- Who is hit. Users of
DirectoryTaxonomyWriterwho callreplaceTaxonomy, as its javadoc suggests, "in conjunction withIndexWriter#addIndexes(Directory...)to replace both the taxonomy and the search index content". No special configuration: the plainDirectoryTaxonomyWriter(Directory)constructor with the writer's own default configuration; theDirectoryis 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.
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.
/*
* 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));
}
}
}
}
# 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.
More files, environment, full output37 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)- After the throw the open writer breaks its own contracts:
addCategorymust return an existing category's ordinal, and the root "always has the ordinal 0". - The trigger is the declared
IOExceptionon a valid source;IndexWriterstays consistent afterdeleteAll()plus a failedaddIndexes(no-index source). - Stale writer state after
replaceTaxonomywas 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 fordim/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-privatedeleteAll()forreindexWithNewOrdinalData, which re-adds every category, root first, before it commits;replaceTaxonomyis unchanged.) - A legitimate trigger. A read error on a valid, closed source raises the
IOExceptionthatreplaceTaxonomydeclares. AfterIndexWriter#deleteAll()and anaddIndexesthat fails on a source with no index (control C2),IndexWriter'smaxDocis 0, matching its pending state; the taxonomy writer instead reportsgetSize()3 while an NRT reader on it sees 0. - No documented escape hatch. The
replaceTaxonomyjavadoc (: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.
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
replaceTaxonomyrolls the writer back and closes it, like a tragic event inIndexWriter: 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
AlreadyClosedExceptioninstead of wrong ordinals. rollback()on a closed writer no longer throws; no upstream test expects it to (testRollbackpasses).- 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/facetmodule 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 forreplaceTaxonomy(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 siblingreindexWithNewOrdinalData, another method. - Not run: nightly tests, a benchmark (the change is off the hot path).
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.)
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.
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.
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)threwIndexNotFoundExceptionthe writer still reported size 4, returned old ordinals, andclose()committed a taxonomy of size 1 with the new label at ordinal 0; an injected read failure on_0.siled 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, thenclose()), 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) underclose()only; with the fix sketch none did. The same run foundaddTaxonomysafe under all 19 single faults.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, commits and downstream trackers | 2026-10-29 | a 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.NET | no report; replaceTaxonomy finds 7 issues and 0 PRs, none about a failing replace |
| Open PRs | 2026-10-29 | the changed files of all 414 open apache/lucene PRs; every issue and PR updated since 2026-10-24 | only #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 lists | 2026-10-29 | JQL on all projects and LUCENENET; dev@, java-user@, issues@ and commits@ | only the same 7 issues and their notifications |
| GitHub (re-check) | 2026-10-29 | search replaceTaxonomy in apache/lucene issues and PRs; PRs naming DirectoryTaxonomyWriter updated since 2026-10-28; the file's latest upstream commits; the main head | nothing new; the file unchanged since f339e24e8ed6 (2024); upstream main 645d4fe14a20 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the repro test fails (gradle and the standalone runner, assertions on and off) |
| 10.5.2, 9.12.3 | affected | the repro test fails (assertions off) |
main a9779ef3c193, branch_10_6 4ae946e5a9b9 | affected | a 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 | affected | the check fails with all three triggers; a successful replace works |
| 4.0.0-ALPHA, 4.0.0, 4.6.1 | two triggers | fails 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 API | no replaceTaxonomy (compile error) |
| main + fix | not affected | the repro test passes; the facet module (307 tests) passes, assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 4-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 | real | Own 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, medium | OpenAI 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, high | Re-ran the independent reproduction's JUnit with a new seed: 5 of 5 fail with assertions on and off. |
| Registration check and credibility audit | keep | The finder's JUnit fails 4 of 4 (two seeds) and passes with the fix; the independent reproduction's controls pass. |
| this doc | as above | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 443 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,529 words
- 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 maina9779ef3c193(lucene/facetandIndexWriter.javaare identical),branch_10_64ae946e5a9b9and the releases 3.6.0 to 10.5.2 listed under history.
- 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 ea57360912edlucene/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-871lucene/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-48lucene/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
- TestReplaceTaxonomyFailure.java (JUnit test):
repro/LFV-978/src/org/apache/lucene/facet/taxonomy/directory/TestReplaceTaxonomyFailure.java(61 lines)
It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe.
assets/LFV-978/bug.png(443 KB)
