All findings · Lucene finding · LFV-646 · infix-failed-build-committed-before-searcher-open

AnalyzingInfixSuggester: a build() that throws after its commit leaves refresh() inert, so the old suggestions stay served and the next add() silently switches to the failed build

With default settings, AnalyzingInfixSuggester.build() commits the new suggestions before opening a searcher on them; if that open throws, refresh() does nothing, lookups keep returning the old suggestions while the index holds the new ones, and the next add() silently swaps in the failed build. Affects 9.2.0 to 10.5.2 and main.

Summary

Priority
Low, rank 388Needs 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
silent wrong results
Since
9.2.0 (LUCENE-10292, 2022)introduced 2022-04-08 · about 4 years
Affected
9.2.0 to 10.5.2 and main
Status
documented
Found by
model-based testing, one I/O fault per run at each file create, open, sync, rename, delete and close

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

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.
The instance (left) and its directory (right) after each call: the rebuild commits [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.
  1. You build an AnalyzingInfixSuggester with the default settings from one suggestion, old apple, then rebuild it from new avocado. The rebuild throws an IOException while it opens the searcher on the new index.
  2. You call refresh() and keep using the instance. You expect lookups to return what its index holds, and add("another apricot") to add exactly one suggestion.
  3. But build() had already committed new avocado before opening the searcher. Its catch block rolls back, which cannot undo a commit, keeps the old searcher and sets writer = null, and with writer == null refresh() does nothing.
  4. You get [old apple] from lookups while a fresh suggester on the same directory sees [new avocado]. After add("another apricot") and refresh(), lookups return [another apricot, new avocado]: old apple is gone, with no error.
  5. 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, yet refresh() keeps showing [old apple] instead of [added apricot, old apple].

Who is affected

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.

Terms

  • Writer: the IndexWriter the suggester writes suggestions with. build() opens a new one in CREATE mode; add()/update() open one in APPEND mode 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 (commitOnClose defaults to true).
  • SearcherManager: what lookups read. refresh() reopens it, but only while a writer is open (:489-498).
  • closeIndexWriterOnBuild (default true, :163) and commitOnBuild (default false): either one makes build() commit (:358-359).

What happens, step by step (main a9779ef3c193)

  1. build() closes any open previous writer (:330-333), which commits any add()/update() not yet refreshed.
  2. It indexes the input with a new CREATE writer, commits (:358-359, default settings), and only then calls setAndCloseOldSearcherManager(new SearcherManager(writer, null)) (:361). Until that line the old SearcherManager is still installed: LUCENE-10292 keeps it so that lookups keep working during a rebuild.
  3. If anything throws after a commit (an IOException while the new reader opens its files, or commit() failing after the new commit point is durable), the catch block (:362-368) runs writer.rollback(), which returns the index to the last commit, i.e. the one just made; sets writer = null; and rethrows. On these paths the old SearcherManager stays. (The hunt's model also found a split in the opposite direction, with commitOnBuild=false and closeIndexWriterOnBuild=false: closing the old SearcherManager throws 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.)
  4. refresh() then does nothing: it refreshes only if (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.
  5. The next add()/update() runs ensureOpen(), which opens an APPEND writer on the latest commit (the failed build) and installs a new SearcherManager on it (:431), so lookups switch to the failed build at that call, even before refresh(). 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. AnalyzingInfixSuggester and BlendedInfixSuggester users (opt-in suggesters, @lucene.experimental, AnalyzingInfixSuggester.java:95) who call build() 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=true behaves the same (executed: the independent reproducer's commitOnBuild=true test and the version probe's commitOnBuild=true scenario). Dictionary size does not matter: the repro's builds have one or two entries.
  • First form (main). An IOException while 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), or commit() failing after the commit point is durable. The served suggestions stay old until the next add()/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 after refresh() until the next add()/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() and lookup() 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 AnalyzingInfixLookupFactory and BlendedInfixLookupFactory pass commitOnBuild=true, and its SuggestComponent's buildOnCommit/buildOnStartup listener catches a build exception, logs it and keeps the same lookup in service (a suggest.build=true request gets the exception back instead); Solr calls only build(), 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.

Minimal repro

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.

TestInfixFailedBuildAfterCommit.java (JUnit test)70 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.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));
    }
  }
}
Command
# 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].

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

More files, environment, full output40 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: 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)

Why it looks unintended

  • 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) runs writer.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). Here add() + refresh() replaces every served suggestion, and in the second form a committed add() is never shown by refresh().
  • History. Up to 9.1.0 build() closed and nulled the SearcherManager first, so a failed build left refresh() and lookup() throwing IllegalStateException: 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 a build() that throws. The record gives no sign that the silent state was designed.
  • Tests. No upstream test in lucene/suggest injects faults into the suggester or asserts the state after a failed build().
  • The oracle holds either way. The repro asserts only that, after refresh(), the instance serves what its directory holds and that add() 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 failed build() 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".

Suggested fix

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.

After the rollback, serve what the directory holds (validated by the checks) (recommended)

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 the try (:331) or after a successful build (:370-373), a second failure inside rollback(), and a recovery open that fails too (then the old view stays, as today). The recovery catches any Throwable and adds it to the original exception as suppressed (untested for VM errors); narrowing it to IOException is 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; TestAnalyzingInfixSuggester and TestBlendedInfixSuggester pass (23 + 9 tests).
  • With the fix: the repro passes 3 of 3 with assertions on and off; TestAnalyzingInfixSuggester plus TestBlendedInfixSuggester pass (32 tests); all of :lucene:suggest:test passes with the repro class added (32 classes, 239 tests, 0 failures, 7 skipped); ecjLintMain, ecjLintTest and checkGoogleJavaFormat pass for suggest; fix.diff applies 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 the new SearcherManager before the commit (validated by the checks)

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.

Make refresh() reopen from the directory when writer == null

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.

Document that the instance must be reopened after build() throws

A javadoc note on build().

Trade-offs

Not run, not recommended: leaves the silent switch in place for everyone who does not read it.

Downstream note

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.

How it was found

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 the commitOnBuild x closeIndexWriterOnBuild configurations. A failed call had to be explainable as applied, not applied, or served = committed.
  • It injected a MockDirectoryWrapper.Failure at 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 in build(); 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the JIRA archive; the web2026-10-26 and 2026-10-27two 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.NET2026-10-27manual gh searches (AnalyzingInfixSuggester, BlendedInfixSuggester, suggester rollback, "suggester was not built", commitOnBuild, ...); Lucene.NET #1259 review comments readnothing 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 PRs2026-10-27changed-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 verifiers2026-10-26 and 2026-10-27JIRA REST searches on the infix lookup factories; the finder's prior-report run; the devil's advocate's 4 targeted GitHub searchesSOLR-6246 (core reload during a build) and others, not this bug

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe 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) affectedthe 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 loudthe failed build is committed too, but refresh() and lookup() throw IllegalStateException: suggester was not built
6.3.0 loudsuggester was not built as well; with default settings build() does not commit there, so the old commit stays
b11b0714297 (LUCENE-10292, branch_9x) introducingin releases/lucene/9.2.0, not in 9.1.0

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateModel-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, highReproduced 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, highOwn 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 realOwn 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-highBuilt 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, highRe-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 APIFresh-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 validatedNew 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.

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

Finding record

LFV-646, snapshot of 2026-11-06

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, plus TestAnalyzingInfixSuggester, TestBlendedInfixSuggester and all of lucene/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 main a9779ef3c193 and ea57360912ed (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.

Lucene and upstream sources

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-194
  • lucene/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)

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

Repro files

About the diagrams

Made in parallel with this doc; 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.

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.