AnalyzingInfixSuggester keeps its suggestions in a small Lucene index in a directory. build() replaces them: it writes a new index, commits it to disk, then opens a searcher on it for lookups. add()/update() change the index; refresh() makes those changes visible.
[new avocado], then fails while opening its searcher; refresh() changes nothing, lookups keep [old apple], and the next add() switches to the failed build. Select the still to open it at full size. Generated with AI, checked against the code.- You build an
AnalyzingInfixSuggesterwith the default settings from one suggestion,old apple, then rebuild it fromnew avocado. The rebuild throws anIOExceptionwhile it opens the searcher on the new index. - You call
refresh()and keep using the instance. You expect lookups to return what its index holds, andadd("another apricot")to add exactly one suggestion. - But
build()had already committednew avocadobefore opening the searcher. Its catch block rolls back, which cannot undo a commit, keeps the old searcher and setswriter = null, and withwriter == nullrefresh()does nothing. - You get
[old apple]from lookups while a fresh suggester on the same directory sees[new avocado]. Afteradd("another apricot")andrefresh(), lookups return[another apricot, new avocado]:old appleis gone, with no error. - A second form needs no I/O fault: an
add("added apricot")not yet refreshed, then a rebuild with one entry over 32766 bytes.build()committed the addition first, yetrefresh()keeps showing[old apple]instead of[added apricot, old apple].
Users of AnalyzingInfixSuggester or BlendedInfixSuggester (opt-in, experimental) who rebuild with the default settings and keep using the instance after build() throws. Any dictionary size. It needs an exception in a narrow window after the commit, so it is rare; no corruption was observed, but the served suggestions silently change later.
Mechanism, terms and numbers721 words
AnalyzingInfixSuggester.build() (and BlendedInfixSuggester, which inherits it) commits the new suggestions with default settings (closeIndexWriterOnBuild=true) and only then opens a SearcherManager on them. If that open throws (or commit() throws after its commit point is durable), the catch block rolls the writer back (which cannot undo a commit), sets writer = null and keeps the old SearcherManager. refresh() is a no-op while writer == null, so lookups keep serving the old suggestions while the directory holds the build that threw, and the next add()/update(), or reopening the directory, silently switches to it. A second form needs no I/O fault: build() first closes, and so commits, a writer holding an unrefreshed add()/update(), and if the rebuild then fails on its input, refresh() never shows that change. Until 9.1.0 the same failure was loud (suggester was not built); LUCENE-10292 made it silent in 9.2.0. Every release from 9.2.0 to 10.5.2 and main is affected. Fix: after the rollback, reopen the served SearcherManager from the directory's latest commit.
- Writer: the
IndexWriterthe suggester writes suggestions with.build()opens a new one inCREATEmode;add()/update()open one inAPPENDmode on the latest commit when none is open (ensureOpen(),AnalyzingInfixSuggester.java:418-434). - Commit: what is durable in the directory, and what a new suggester on the same directory loads (
:287-290).IndexWriter.close()also commits (commitOnClosedefaults to true). - SearcherManager: what lookups read.
refresh()reopens it, but only while a writer is open (:489-498). closeIndexWriterOnBuild(default true,:163) andcommitOnBuild(default false): either one makesbuild()commit (:358-359).
build()closes any open previous writer (:330-333), which commits anyadd()/update()not yet refreshed.- It indexes the input with a new
CREATEwriter, commits (:358-359, default settings), and only then callssetAndCloseOldSearcherManager(new SearcherManager(writer, null))(:361). Until that line the oldSearcherManageris still installed: LUCENE-10292 keeps it so that lookups keep working during a rebuild. - If anything throws after a commit (an
IOExceptionwhile the new reader opens its files, orcommit()failing after the new commit point is durable), the catch block (:362-368) runswriter.rollback(), which returns the index to the last commit, i.e. the one just made; setswriter = null; and rethrows. On these paths the oldSearcherManagerstays. (The hunt's model also found a split in the opposite direction, withcommitOnBuild=falseandcloseIndexWriterOnBuild=false: closing the oldSearcherManagerthrows after the new one is installed,:298-300, so the rolled-back, never-committed build is served while the directory keeps the previous build; model only.) refresh()then does nothing: it refreshes onlyif (writer != null), with the comment "writer was committed and closed after the index was built and before searchMgr was constructed, so refresh is unnecessary" (:496-497), which is false on this path.- The next
add()/update()runsensureOpen(), which opens anAPPENDwriter on the latest commit (the failed build) and installs a newSearcherManageron it (:431), so lookups switch to the failed build at that call, even beforerefresh(). Reopening the directory loads the same commit.
Second form (no I/O fault). After build([old apple]), an add("added apricot") that is not yet refreshed, then a rebuild whose input fails: an InputIterator error, or one entry over IndexWriter's 32766-byte term limit, which addDocument rejects with IllegalArgumentException. Step 1 has already committed the addition; the rebuild's own writer is rolled back; refresh() cannot show the committed addition: served [old apple], stored [added apricot, old apple].
What is stored is complete. After the first form the directory holds the complete dictionary build() was asked to produce: every entry was indexed and committed before the failure. No corruption was observed in the exercised cases (reopened indexes were readable; the hunt's safety probe passed CheckIndex). The defect is that build() reports failure while its result is already durable, refresh() cannot publish it, and an unrelated call or a restart publishes it later. A later successful build() restores consistency.
BlendedInfixSuggester extends AnalyzingInfixSuggester without overriding build() (BlendedInfixSuggester.java:56) and behaves the same (executed in the finder's test).
Exposure and likelihood361 words
- Who is exposed.
AnalyzingInfixSuggesterandBlendedInfixSuggesterusers (opt-in suggesters,@lucene.experimental,AnalyzingInfixSuggester.java:95) who callbuild()again and keep using the instance after it throws, which is the natural reaction (log the error, keep serving). The default constructor suffices (closeIndexWriterOnBuild=true);commitOnBuild=truebehaves the same (executed: the independent reproducer'scommitOnBuild=truetest and the version probe'scommitOnBuild=truescenario). Dictionary size does not matter: the repro's builds have one or two entries. - First form (main). An
IOExceptionwhile the new reader opens its segment files right after the commit (for example a transient storage error or an open-file or memory-map limit; only synthetic faults were run), orcommit()failing after the commit point is durable. The served suggestions stay old until the nextadd()/update()or a restart, then silently become the failed build's. - Second form (secondary). An
add()/update()not yet refreshed, then a rebuild that fails on its input (one entry over 32766 bytes is enough). The change is committed but invisible afterrefresh()until the nextadd()/update()or a reopen. It is weaker, because a successful rebuild would have dropped that change anyway. - No error at lookup time. Only
build()throws;refresh()andlookup()succeed, and nothing tells the caller that the served view and the directory differ. - Deterministic once triggered: the finder's JUnit failed 80 of 80 runs over 20 seeds; the repro fails the same with assertions on and off and in 10 of 10 iterations with a random seed.
- Downstream, by code reading only. Solr's
AnalyzingInfixLookupFactoryandBlendedInfixLookupFactorypasscommitOnBuild=true, and itsSuggestComponent's buildOnCommit/buildOnStartup listener catches a build exception, logs it and keeps the same lookup in service (asuggest.build=truerequest gets the exception back instead); Solr calls onlybuild(), so it can reach the first form (stale until the next successful build or a core reload, which loads the failed build), not the second. No use was found in Elasticsearch or OpenSearch. - How likely. Rare: every default rebuild is exposed, but only an exception in that narrow window, or a rebuild failing right after an unrefreshed change, triggers it. No user report since 9.2.0 (2022) was found.
The test shows that AnalyzingInfixSuggester.build() (default settings) commits, then throws while it opens the new searcher. refresh() becomes a no-op, lookups keep serving the old suggestions, and the next add() silently switches to the failed build.
/*
* 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.search.suggest.analyzing;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.search.suggest.FileDictionary;
import org.apache.lucene.tests.analysis.MockAnalyzer;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestInfixFailedBuildAfterCommit extends LuceneTestCase {
private static List<String> lookup(AnalyzingInfixSuggester suggester) throws IOException {
return suggester.lookup("a", 10, true, false).stream()
.map(r -> r.key.toString())
.sorted()
.toList();
}
public void testAddAfterFailedBuild() throws Exception {
MockDirectoryWrapper dir = newMockDirectory();
MockDirectoryWrapper.Failure failure =
new MockDirectoryWrapper.Failure() {
@Override
public void eval(MockDirectoryWrapper d) throws IOException {
if (doFail && callStackContains(SearcherManager.class, "<init>")) {
doFail = false;
throw new IOException("simulated I/O error while build() opens the new searcher");
}
}
};
dir.failOn(failure);
try (Analyzer analyzer = new MockAnalyzer(random());
AnalyzingInfixSuggester suggester = new AnalyzingInfixSuggester(dir, analyzer)) {
suggester.build(new FileDictionary(new StringReader("old apple")));
failure.setDoFail();
expectThrows(
IOException.class,
() -> suggester.build(new FileDictionary(new StringReader("new avocado"))));
suggester.refresh();
List<String> expected = new ArrayList<>(lookup(suggester));
expected.add("another apricot");
expected.sort(null);
suggester.add(new BytesRef("another apricot"), null, 1, null);
suggester.refresh();
// expected the served [old apple] plus the added one; actual: [another apricot, new avocado]
assertEquals(expected, lookup(suggester));
}
}
}
# in a copy of this finding's repro folder (repro/LFV-646/, 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/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/suggest/build/libs/lucene-suggest-$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.search.suggest.analyzing.TestInfixFailedBuildAfterCommit
- Expected vs actual
- Expected: after the failed build and refresh(), add() + refresh() serves [another apricot, old apple], which is what was served plus the one added suggestion. Actual (-ea and -da): [another apricot, new avocado].
More files, environment, full output40 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: expected:<[another apricot, old apple]> but was:<[another apricot, new avocado]>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.search.suggest.analyzing.TestInfixFailedBuildAfterCommit.testAddAfterFailedBuild(TestInfixFailedBuildAfterCommit.java:67)- The catch block rolls back and rethrows, treating the build as failed, but a rollback cannot undo the commit already made.
refresh()skips its work, commenting that the served searcher was built after the commit; after a failed build that is false.- Until 9.1.0 the same failure was loud ("suggester was not built"); 9.2.0 made it silent.
The full argument306 words
- The code's own failure handling.
build()'s catch (AnalyzingInfixSuggester.java:362-368) runswriter.rollback(); writer = null;and rethrows, so it treats the build as failed. A rollback discards only uncommitted work (IndexWriter.java:2566-2574), so the commit made a few lines earlier survives and nothing brings the served view in line with it. refresh()'s comment states the broken invariant (:496-497): "writer was committed and closed after the index was built and before searchMgr was constructed, so refresh is unnecessary".- The javadoc.
refresh(): "Reopens the underlying searcher" (:486).add(): "Adds a new suggestion ... After adding or updating a batch of new suggestions, you must call refresh in the end in order to see the suggestions in lookup" (:436-439);update()the same (:447-451). Hereadd()+refresh()replaces every served suggestion, and in the second form a committedadd()is never shown byrefresh(). - History. Up to 9.1.0
build()closed and nulled theSearcherManagerfirst, so a failed build leftrefresh()andlookup()throwingIllegalStateException: suggester was not built(executed on 9.1.0, 9.0.0, 8.11.4, 6.4.0). LUCENE-10292 (9.2.0; CHANGES: "Fix AnalyzingInfixSuggester / BlendedInfixSuggester to correctly return existing lookup() results during concurrent build()") kept the old manager and left the failure path unchanged; nobody in that issue discusses abuild()that throws. The record gives no sign that the silent state was designed. - Tests. No upstream test in
lucene/suggestinjects faults into the suggester or asserts the state after a failedbuild(). - The oracle holds either way. The repro asserts only that, after
refresh(), the instance serves what its directory holds and thatadd()adds one suggestion. "The failed build is applied" and "it is not applied" both pass; only the split fails, and a control in which the failedbuild()writes no commit passes.
The devil's-advocate check213 words
The strongest works-as-designed case (intended-behaviour checker, the cross-model refute lens, the adjudication): Lookup.build promises nothing about failures ("Builds up a new internal Lookup representation", Lookup.java:178-182); the class is @lucene.experimental; the commitOnBuild javadoc says "Call commit after the index has finished building. This would persist the suggester index to disk" (:192-194); and in LUCENE-10292 a reviewer said rebuild behaviour "isn't guaranteed or documented by the Lookup class", while its author said "I didn't set out to make any hard & fast guarantee about the thread safety of all lookups".
This doubt is real: nothing documents that the instance stays usable after build() throws, so the post-failure semantics may be debated. It does not settle the case: those LUCENE-10292 remarks are about concurrent lookup() during build(), not about failures. Silence about failure semantics does not license a state in which refresh() is permanently inert and an unrelated add() swaps the dictionary; the code's own rollback and refresh() comment point the other way; and even if a finished build may stay durable, the repro still fails, because the instance does not serve it. @lucene.experimental covers API stability, not wrong results. The refute lens returned "uncertain", high confidence in the facts, "the only remaining doubt is contractual"; every other check returned "real".
Recommended After the rollback, serve what the directory holds
In build()'s catch block, after the rollback, reopen the served SearcherManager from the directory's latest commit, so what is served is always what is durable (11 added lines, one site). The repro passes with assertions on and off, and all of lucene/suggest passes (239 tests).
writer = null;
}
+ // The rollback cannot undo what is already durable: the previous writer was closed (and so
+ // committed) above, and this build may have been committed before the failure. Serve what
+ // the directory now holds; otherwise refresh(), a no-op once writer == null, can never
+ // bring the served suggestions back in line with the index.
+ try {
+ if (DirectoryReader.indexExists(dir)) {
+ setAndCloseOldSearcherManager(new SearcherManager(dir, null));
+ }
+ } catch (Throwable t2) {
+ t.addSuppressed(t2);
+ }
throw t;All options and trade-offs4 options ยท 701 words
Option A was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); option B was checked against the repros with its class shadowed onto the main jars; C and D were not run.
In build()'s catch block (AnalyzingInfixSuggester.java:362-368), after writer.rollback(); writer = null;, install new SearcherManager(dir, null) on the latest commit when an index exists, suppressing a second failure into the original exception. Whichever step failed (the build's own commit, a commit that threw after it was durable, or the previous writer's commit on close), the latest commit is exactly what is durable, so serving it restores the invariant refresh() relies on, and the next add() adds to what is served. build() still rethrows.
writer = null;
}
+ // The rollback cannot undo what is already durable: the previous writer was closed (and so
+ // committed) above, and this build may have been committed before the failure. Serve what
+ // the directory now holds; otherwise refresh(), a no-op once writer == null, can never
+ // bring the served suggestions back in line with the index.
+ try {
+ if (DirectoryReader.indexExists(dir)) {
+ setAndCloseOldSearcherManager(new SearcherManager(dir, null));
+ }
+ } catch (Throwable t2) {
+ t.addSuppressed(t2);
+ }
throw t;Trade-offs
- A policy choice, not a transactional rollback. After the first form the instance serves the build that threw (the complete requested dictionary), as a restart would. Maintainers may prefer other semantics (option B plus a fallback, a failed-state flag, or documentation).
- Not covered: an exception from the
writer.close()before thetry(:331) or after a successful build (:370-373), a second failure insiderollback(), and a recovery open that fails too (then the old view stays, as today). The recovery catches anyThrowableand adds it to the original exception as suppressed (untested for VM errors); narrowing it toIOExceptionis a reasonable variant. - Cost: one extra directory reader open, only on the failure path. No format or API change.
What the checks validated
- Stock main
a9779ef3c193(gradle, seed DEADBEEF): the repro fails 2 of 3 with assertions on and off;TestAnalyzingInfixSuggesterandTestBlendedInfixSuggesterpass (23 + 9 tests). - With the fix: the repro passes 3 of 3 with assertions on and off;
TestAnalyzingInfixSuggesterplusTestBlendedInfixSuggesterpass (32 tests); all of:lucene:suggest:testpasses with the repro class added (32 classes, 239 tests, 0 failures, 7 skipped);ecjLintMain,ecjLintTestandcheckGoogleJavaFormatpass for suggest;fix.diffapplies cleanly to a clean tree. - With the fix shadowed onto the main jars: the finder's JUnit 4 of 4 and the independent reproducer's 9 of 9 (including
commit()throwing after the commit is durable) pass. - Earlier, in the hunt, the same change: 0 violations in the length-2 model run (1,952 without it), and the over-long-entry test and the downstream probe pass.
Open new SearcherManager(writer, null) first, then commit, closing the new manager if the commit throws. A failure to open the reader then happens before anything is durable, so the build is cleanly "not applied".
Trade-offs
Covers only the reader-open case. Executed with the class shadowed onto the main jars: the second form still fails in the repro (served [old apple] vs stored [added apricot, old apple]), in the finder's JUnit (1 of 4) and in the independent reproducer's JUnit, which also still fails its commit-throws-after-durable test (2 of 9). It can be combined with option A, which then handles the rest.
What the checks validated
Repros only (classpath runner, seed DEADBEEF); the module suite was not run with it.
When no writer is open, let refresh() reopen the served searcher from the latest commit.
Trade-offs
Not run. Changes the success path: every refresh() after a successful build with closeIndexWriterOnBuild=true would check the directory, where today it costs nothing.
A javadoc note on build().
Trade-offs
Not run, not recommended: leaves the silent switch in place for everyone who does not read it.
If an application narrows a suggestion's contexts with update() and a rebuild then fails before refresh(), lookups filtered by the removed context keep returning that suggestion until the next add()/update() or a reopen.
Model-based testing of the suggester's lifecycle on the real jar: every script of up to 3 calls, one I/O fault per run at each file create, open, sync, rename, delete or close, checked against an exact model of served and committed suggestions. At length 2, all 1,952 violations had the fault in build(); with the fix, 0.
The method in full235 words
Method: operation-level model-based testing on the stock jar (semi-formal tier), in a hunt unit on the infix suggester's lifecycle.
- The harness ran scripts of
build(),add(),update(),refresh(),commit()and reopen calls against an exact reference model of the served, pending and committed suggestion sets and the writer state, over thecommitOnBuildxcloseIndexWriterOnBuildconfigurations. A failed call had to be explainable as applied, not applied, or served = committed. - It injected a
MockDirectoryWrapper.Failureat every create, open, sync, rename, delete and close point of every call (the default boundary mode; buffered writes excluded, and no recorded run used the all-points mode), one per run, bounded-exhaustively: length 1 (36 scripts, 2,520 fault runs, 108 violations, all this class), length 2 (324 script-config pairs, 69,760 fault runs, 1,952 violations, all with the fault inbuild(); 0 with the fix shadowed in) and length 3 on separate build machines (2,916 script-config pairs, 1,104,615 fault runs). - The hypothesis (a rollback after a commit) came from reading the code while the model was written; the model then flagged it independently. The second form came from triaging a state the model tolerated and was confirmed with a JUnit.
- Found 2026-10-26 on main
a9779ef3c193; then checked by an independent reproducer, an intended-behaviour devil's advocate, reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history, prior-report and security follow-up, and a credibility audit.
No prior report found (2026-10-27) in two runs of the hunt's prior-report tool (711 distinct hits in the final one), manual GitHub, JIRA and list searches, and the changed files of the 409 open PRs (4 very large PRs by title only). The closest items built the ingredients without discussing failures.
Searches4 searches
Verdict new: no report by anyone. The closest remark is an automated review comment on the Lucene.NET port (apache/lucenenet#1259) that a failed Build() can leave the manager null there, a different, loud symptom; the maintainer kept the port faithful to the Java code. Keyword search cannot prove absence: GitHub search does not index review comments, a verifier hit the rate limit, and private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the JIRA archive; the web | 2026-10-26 and 2026-10-27 | two runs of the hunt's prior-report tool (final run: 26 GitHub issue/PR, 4 commit, 4 downstream, 32 list, 8 JIRA and 6 web queries; comments of the top 20 candidates read) | 711 distinct hits, 0 high; related only: #11328 (LUCENE-10292), #8615 (LUCENE-7564), #6951 (LUCENE-5889), #7110 (LUCENE-6048), #10126 (LUCENE-9084) |
| apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET | 2026-10-27 | manual gh searches (AnalyzingInfixSuggester, BlendedInfixSuggester, suggester rollback, "suggester was not built", commitOnBuild, ...); Lucene.NET #1259 review comments read | nothing on point; Lucene.NET #1259: a review remark on a null manager after a failed Build() in the port (different symptom) |
| the 409 open apache/lucene PRs | 2026-10-27 | changed-file scan; 4 PRs with more than 100 changed files checked by title only | #16875 (javadoc typos) and #13948 (createResults()) touch the file, neither build(), refresh() nor ensureOpen() |
| SOLR JIRA and the hunt's verifiers | 2026-10-26 and 2026-10-27 | JIRA REST searches on the infix lookup factories; the finder's prior-report run; the devil's advocate's 4 targeted GitHub searches | SOLR-6246 (core reload during a build) and others, not this bug |
Silent since 9.2.0: LUCENE-10292 (5015dc6dbb8, branch_9x b11b0714297, 2022) kept the old searcher during a rebuild but left the failure path as it was. Until 9.1.0 the same failure threw "suggester was not built". Executed on all 31 releases from 9.2.0 to 10.5.2 and main; not fixed upstream.
Versions tested5 rows
Introduced by LUCENE-10292 (now GitHub #11328, "AnalyzingInfixSuggester thread safety: lookup() fails during (re)build()", by @hossman; JIRA patches, no PR): main 5015dc6dbb8 and the branch_9x cherry-pick b11b0714297, both 2022-04-08, first released in 9.2.0 (10.0.0 on 10.x). It removed searcherMgr.close(); searcherMgr = null; at the start of build() so that lookups keep working during a rebuild, and installs the new manager with setAndCloseOldSearcherManager after the commit; the failure path stayed rollback(); writer = null.
Older ingredients: commit() inside build() before the new SearcherManager (LUCENE-5889, 5.0.0); the default commit when closeIndexWriterOnBuild=true and the refresh() no-op with its comment (LUCENE-7564, 6.4.0). #15134 (b85f030c6cc, 2025, main only) rewrote the success flag as catch (Throwable) with the same behaviour. The last commits to the file are #16255 on main (2026-06-14) and #16592 on branch_10x (2026-09-05), both build-related.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the repro fails 2 of 3 through gradle (assertions on and off) and the classpath runner (-ea, -da); passes with the fix |
| 10.0.0 to 10.5.2, 9.2.0 to 9.12.3 (31 releases) | affected | the version probe: served [old apple], stored [new avocado]; the repro fails 2 of 3 on 10.5.2, 9.12.3 and 9.2.0 |
| 9.1.0, 9.0.0, 8.11.4, 6.4.0 | loud | the failed build is committed too, but refresh() and lookup() throw IllegalStateException: suggester was not built |
| 6.3.0 | loud | suggester was not built as well; with default settings build() does not commit there, so the old commit stays |
b11b0714297 (LUCENE-10292, branch_9x) | introducing | in releases/lucene/9.2.0, not in 9.1.0 |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; reviews by a model from another family (OpenAI GPT-5.6) reproduced it with their own tests and agreed on the root cause; an adjudication and an audit re-ran it with new seeds, and this doc's gradle runs reproduce it on a9779ef3c193.
Checks and verdicts420 words
Every executing check wrote or re-ran its own repro on stock main; every control passed and every fix check passed. The one doubt raised (the refute lens) is contractual: nothing documents the state after build() throws.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Model-based test, then a 4-test public-API JUnit: 4 of 4 fail on main (-ea, -da), 10.5.2 and 9.12.3, 80 of 80 over 20 seeds; pass with the fix; upstream TestAnalyzingInfixSuggester and TestBlendedInfixSuggester 32 of 32 with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6) | uncertain (facts reproduced) | Reproduced the first form with its own FilterDirectory test; agreed with the root cause and the fix; its only doubt is contractual (Lookup.build promises no failure atomicity; experimental class). |
| cross-model review, intended lens (OpenAI GPT-5.6) | real, high | Reproduced both forms with its own tests; rated the intended-behaviour argument moderate but insufficient. |
| root-cause review (the cross-model review, OpenAI GPT-5.6) | real, high | Own test fails 2 of 3 (control passes) on main with -ea and -da and passes 3 of 3 with the fix; listed the cases outside the fix. |
| independent reproducer | real | Own 9-test JUnit from the claim alone: 6 of 9 fail on main (-ea, -da), 10.5.2, 9.12.3 and 9.2.0, 3 controls pass; loud on 9.1.0; 9 of 9 with the fix. Corrected the affected versions to 9.2.0. |
| intended-behaviour devil's advocate | real, moderate-high | Built the strongest works-as-designed case and showed why it fails; found the data-only trigger (one entry over 32766 bytes), which fails on main and 9.2.0 and passes with the fix. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test with a new seed (-ea, -da: 6 of 9 fail), the finder's test with -da (4 of 4 fail) and the independent reproduction's test with the fix (9 of 9 pass). |
| registry re-runs and credibility audit | confirmed; score lowered for the experimental API | Fresh-seed re-runs on main: the finder's test 4 of 4 fail (-ea and -da), the independent reproduction 6 of 9, the intended-behaviour check 1 of 1, the finder's test with the fix 0 of 4. The audit confirmed the root cause by reading and stressed that the stored content is the complete requested dictionary. |
| this doc | reproduced; fix validated | New 3-test JUnit through gradle: 2 of 3 fail on stock main with assertions on and off, 3 of 3 pass with the fix, and all of lucene/suggest passes with it (239 tests); also run on 10.5.2, 9.12.3, 9.2.0 and 9.1.0. |
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 497 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,443 words
- Registry title
- AnalyzingInfixSuggester.build() can commit and then throw, and its catch block leaves writer == null, so refresh() becomes a no-op: the old suggestions stay served while the directory holds the build that threw, and the next add()/update() or a reopen silently switches to it (default settings, since 9.2.0)
- Area
- suggest / analyzing infix suggester (AnalyzingInfixSuggester.build() failure path; inherited unchanged by BlendedInfixSuggester)
- Priority
- Low, rank 388: Needs build() to throw in a narrow window after its commit, or a failed rebuild after an unrefreshed add, followed by continued use of the suggester.
- Impact class
- silent wrong results
- Impact note
- Applications that rebuild an AnalyzingInfixSuggester or BlendedInfixSuggester with default settings (closeIndexWriterOnBuild=true; commitOnBuild=true works too), at any size, on 9.2.0 through 10.5.2 or main, and keep using the instance after build() throws are hit: when the exception comes after build()'s commit (an IOException while the new reader opens, or commit() failing after the new commit point is durable), refresh() does nothing, lookups keep serving the previous suggestions while the directory holds the failed build, and the next add()/update() or a restart silently swaps the whole served dictionary for the failed build's; when a rebuild instead fails on its input (an InputIterator error, or one entry over 32766 bytes) after an add()/update() that was not yet refreshed, that change is committed but stays invisible after refresh(); there is no error beyond build()'s own exception and no index damage, and Solr, which only calls build(), can reach only the first form.
- Summary
- You rebuild an AnalyzingInfixSuggester with default settings, build() throws an IOException, you call refresh() and keep using the instance, and you expect it to serve what its index holds, so that a later add() adds exactly one suggestion. Instead build() has already committed the new index (closeIndexWriterOnBuild defaults to true) before it opens the SearcherManager on it; when that open fails, the catch block rolls back (which cannot undo the commit), sets writer = null and keeps the old SearcherManager, so refresh() is a no-op, lookups serve the old suggestions ([old apple]) while the directory holds the failed build ([new avocado]), and after the next add() lookups return [another apricot, new avocado], with the old suggestion gone. A second trigger needs no I/O fault: build() first closes, and so commits, a writer that holds an add() or update() not yet refreshed, and if the rebuild's input then throws, refresh() never shows that committed change.
- Introduced
- 5015dc6dbb89a2d3f9c2cd0eb1674f6f146d09b4 on main (first released in 10.0.0) and its branch_9x cherry-pick b11b0714297ab5f8005aa01001824acc6ef488e7 (first released in 9.2.0): LUCENE-10292 (now GitHub issue #11328, 'AnalyzingInfixSuggester thread safety: lookup() fails during (re)build()', by hossman; reviewed as JIRA patches, no GitHub PR). It stopped closing and nulling searcherMgr at the start of build(), so that lookups keep working during a rebuild, and installs the new manager only after the commit (setAndCloseOldSearcherManager, main :361); the failure path (rollback; writer = null) stayed as it was, so a build that throws after its commit now leaves the old manager installed and refresh() inert [read: git blame and git log in the history clone; exec: the version probe and the candidate repro are loud on 9.1.0 and fail silently on 9.2.0]. Older ingredients: commit() before new SearcherManager in build() (LUCENE-5889, 4a485d68771 on trunk, e4731cf8a36, 5.0.0) and the default commit when closeIndexWriterOnBuild=true plus refresh()'s writer == null no-op (LUCENE-7564, f9a0693bf98 on main, branch_6x 4fedb640ab6, 6.4.0). #15134 (b85f030c6cc, main only) rewrote the success flag as catch (Throwable) without changing behaviour (2022-04-08 (main 5015dc6dbb8 and the branch_9x cherry-pick b11b0714297))
- First affected version
- 9.2.0 [exec] (10.0.0 on the 10.x line [exec]). 9.1.0 is the last release without the silent form [exec]: a failed build is committed there too with default settings, but the instance is left with no searcher, so refresh() and lookup() throw IllegalStateException 'suggester was not built' (loud). The same loud behaviour on 9.0.0, 8.11.4 and 6.4.0 [exec]; 6.3.0 does not commit inside build() with default settings [exec]
- Affected versions
- All 31 releases from 9.2.0 through 9.12.3 and 10.0.0 through 10.5.2, and main a9779ef3c193; not fixed upstream. [exec] The finder's JUnit fails 4 of 4 on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.2.0 (-ea and -da); the version probe shows the served-versus-committed split on every one of the 31 releases and on main, and the loud form on 9.1.0, 9.0.0, 8.11.4, 6.4.0 and 6.3.0; the independent reproduction's JUnit fails 6 of 9 (its 3 controls pass) on main (-ea and -da), 10.5.2, 9.12.3 and 9.2.0; the intended-behaviour check's over-long-entry test fails on main (-ea and -da) and 9.2.0; the re-runs on main (fresh seeds) fail the same way. Not affected, loud instead: 9.1.0 and older [exec 9.1.0, 9.0.0, 8.11.4, 6.4.0 and 6.3.0; no 5.x or 7.x release was run]. BlendedInfixSuggester inherits build() unchanged [exec in the finder's test only]. All runs on aarch64 with JDK 25 (releases before 10.x with API ports of the probe)
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that stayed undecided, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check, code reading; also reached by randomized/MBT
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record verdict new): the scripted prior-report search's final run (26 GitHub issue/PR, 4 commit, 4 downstream (Elasticsearch, OpenSearch, Solr), 32 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA and 6 web queries; 711 distinct hits, 0 high) plus the finder's run, the intended-behaviour check's targeted GitHub searches and the registration check's read of the Lucene.NET review comments. Closest items, context only: #11328 / LUCENE-10292 (9.2.0, the change that made the failure silent; its discussion is about lookup() during a concurrent build()), #8615 / LUCENE-7564 (6.4.0, the default commit and refresh()'s no-op), #6951 / LUCENE-5889 (commitOnBuild), SOLR-6246 (core reload during build), and apache/lucenenet#1259, where an automated review comment on the 4.8 port said a failed Build() can leave the manager null (a different symptom in another code base) and that the Refresh() no-op comment states the wrong order. Not fixed upstream: the last commits to AnalyzingInfixSuggester.java are #16255 (main, 2026-06-14) and #16592 (branch_10x, 2026-09-05); the only open PRs on the file, #16875 (javadoc typos) and #13948 (createResults), do not touch build(), refresh() or ensureOpen().
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): the repro JUnit through gradle in a clean worktree, stock and with the fix, assertions on and off, plusTestAnalyzingInfixSuggester,TestBlendedInfixSuggesterand all oflucene/suggest(32 classes, 239 tests) with the fix (2026-10-28); through the hunt's classpath runner, the same JUnit on main (-ea, -da, fix shadowed), 10.5.2, 9.12.3, 9.2.0 and 9.1.0. Hunt runs on maina9779ef3c193andea57360912ed(identical suggest code); the version probe on all 31 releases from 9.2.0 to 10.5.2 and on 9.1.0, 9.0.0, 8.11.4, 6.4.0 and 6.3.0. OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:95 (at a9779ef3c193) (@lucene.experimental), :163 (DEFAULT_CLOSE_INDEXWRITER_ON_BUILD = true), :192-194 (commitOnBuild javadoc), :293-305 (setAndCloseOldSearcherManager), :327-375 (build(): previous writer closed at :330-333, commit() at :358-359, new SearcherManager at :361, catch (Throwable) with rollback and writer = null at :362-368, close at :370-373), :418-434 (ensureOpen(): APPEND writer over the latest commit and a new SearcherManager), :436-459 (add()/update() javadoc: 'you must call refresh in the end in order to see the suggestions in lookup'), :485-498 (refresh(): no-op when writer == null, invariant comment at :496-497)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/BlendedInfixSuggester.java:56 (at a9779ef3c193) (extends AnalyzingInfixSuggester; no build() override)git -C a Lucene main checkout log ea57360912ed..a9779ef3c193 -- lucene/suggest (empty: the reviews and runs on ea57360912ed exercised identical suggest code)
- The finding in 30 seconds
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:163, (at a9779ef3c193) :287-290, :293-305, :327-375 (330-333 previous writer closed; 358-359 commit; 361 new SearcherManager; 362-368 catch), :418-434 (ensureOpen), :489-498 (refresh and its comment)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:102 (at a9779ef3c193) (DEFAULT_COMMIT_ON_CLOSE = true); IndexWriter.java:258 (MAX_TERM_LENGTH), :2566-2574 (rollback javadoc)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/BlendedInfixSuggester.java:56 (at a9779ef3c193)
- Who is affected
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:95 (at a9779ef3c193) (@lucene.experimental), :163, :192-194, :358-361
- Why it looks unintended
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:362-368, (at a9779ef3c193) :436-439, :447-451, :486, :496-497, :192-194lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:178-182 (at a9779ef3c193)GitHub apache/lucene#11328 comments (read-only API, 2026-10-28): the two quoted remarks (hossman 2022-04-28T21:41Z, dweiss 2022-04-29T07:26Z)
- TestInfixFailedBuildAfterCommit.java (JUnit test):
repro/LFV-646/src/org/apache/lucene/search/suggest/analyzing/TestInfixFailedBuildAfterCommit.java(70 lines)
Made in parallel with this doc; its values come from an executed probe on main a9779ef3c193 (assertions on and off, and with the fix shadowed in), 10.5.2, 9.2.0 and 9.1.0, and its code lines from a9779ef3c193.
assets/LFV-646/bug.png(497 KB)
![Summary card titled AnalyzingInfixSuggester.build() can commit, then throw; refresh() cannot catch up. With default settings commit() runs before new SearcherManager, and the catch rolls back to that commit, sets writer = null and keeps the old searcher. A table of calls in order: build("old apple") returns, served [old apple], latest commit segments_1 [old apple]; build("new avocado") throws an IOException, served [old apple], latest commit segments_2 [new avocado]; refresh() returns as a no-op, served [old apple], commit [new avocado]; add("another apricot") and refresh() return, served [another apricot, new avocado]. Expected: after refresh(), lookups serve what the directory holds and add() adds exactly one suggestion. Actual: [old apple] is served while the directory holds [new avocado], and the next add() swaps in the build that threw. Notes: a second trigger with no I/O fault (an unrefreshed add() and a rebuild whose input throws); no index damage; BlendedInfixSuggester inherits build(); the fix reopens the served SearcherManager from the latest commit in the catch. Affected: all 31 releases 9.2.0 to 10.5.2 and main; 9.1.0 and older throw suggester was not built instead. Rare.](../assets/LFV-646/bug.png)