All findings · Lucene finding · LFV-263 · infix-ensureopen-publishes-writer-before-searchermanager

AnalyzingInfixSuggester: one failed add() after build() makes every later refresh() throw AlreadyClosedException

After a default build(), one I/O error in the first add() leaves AnalyzingInfixSuggester stuck: the retried add() succeeds, but every refresh() throws AlreadyClosedException: this IndexWriter is closed until the next build(). ensureOpen() stores its new writer before the searcher manager that goes with it.

Summary

Priority
Low, rank 632AnalyzingInfixSuggester's direct NRT add and refresh API can throw AlreadyClosedException in a race window after build(), loud and rarely reached.
Impact
wrong error
Since
6.4.0 (LUCENE-7564, 2016)introduced 2016-11-16 · about 9 years
Affected
6.4.0 to 10.6.0 and main
Status
documented
Found by
TLA+ model checking (TLC); the single-threaded trigger by code reading

AI-assisted page: this finding was first signalled by a formal tool's own output and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The infix suggester keeps two fields: writer, the IndexWriter that add() and update() write to, and searcherMgr, the SearcherManager that serves lookups. refresh() uses writer != null to decide whether the manager needs a refresh.

Background: build(), add() and refresh()

  1. AnalyzingInfixSuggester keeps its suggestions in a small Lucene index: build() indexes a whole input, add()/update() add single suggestions later, and refresh() makes those visible to lookup().
  2. By default build() commits and closes its writer at the end and sets writer = null, but keeps the searcher manager opened on that writer. Lookups keep working; refreshing that manager would fail, because its writer is closed, so refresh() skips it while writer is null.
  3. The first add() after build() calls ensureOpen(), which opens a new writer and a new searcher manager on it, and swaps out the old manager.
Diagram of an AnalyzingInfixSuggester after build(): writer is null and the searcher manager M1 is opened on build()'s closed writer W1. The first add() sets writer to a new writer W2, opening its manager M2 fails, and writer stays W2 next to M1. The retried add() succeeds, and refresh() refreshes M1, which asks the closed W1 for changes: AlreadyClosedException, this IndexWriter is closed.
The suggester's two fields, writer and searcherMgr, through build(), the failed first add(), the retried add() and the refresh() that reaches build()'s closed writer. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You build an AnalyzingInfixSuggester with default settings from one suggestion, lend me your ear. build() closes its IndexWriter at the end but keeps the searcher manager it opened on it, so refresh() is now a no-op.
  2. You call add("a penny saved"). The first add after build() opens a new writer and a new searcher manager. One I/O error while that manager opens its reader makes add() throw IOException. You retry the add(): it succeeds.
  3. You call refresh(), as the add() javadoc asks, and expect lookup("penn") to return 1 suggestion.
  4. Instead refresh() throws AlreadyClosedException: this IndexWriter is closed, and so does every later refresh(); lookup("penn") returns 0 until build() runs again or the suggester is reopened. The index is not damaged, and commit() still saves the addition.
  5. Why: ensureOpen() sets the writer field (line 423) before it installs the new manager (line 431), and does not undo that when the install fails. refresh() reads writer != null as "the manager is live" and refreshes build()'s old manager over the closed writer. A background refresh() during the first add() after a build() hits the same gap briefly.

Who is affected

Applications that use AnalyzingInfixSuggester or BlendedInfixSuggester as a near-real-time suggester (build(), then add()/update() and refresh()) with default settings, at any size, on 6.4.0 to 10.6.0 and main. Solr's suggest component calls only build(), never add(), update() or refresh(). Rare: no public report was found, although the code dates from 2017.

Mechanism, terms and numbers628 words

By default (closeIndexWriterOnBuild=true) AnalyzingInfixSuggester.build() closes its IndexWriter at the end but keeps the SearcherManager it opened on that writer, and refresh() is then a deliberate no-op because the writer field is null (AnalyzingInfixSuggester.java:493-497 at 645d4fe14a20). The first add()/update() after build() runs ensureOpen(), which assigns the new writer to the field (:423/:427) and only then opens and installs a SearcherManager on it (:431). If that constructor throws, nothing undoes the assignment: the retried add() succeeds, and every refresh() now refreshes build()'s old manager and fails with AlreadyClosedException: this IndexWriter is closed; new suggestions stay invisible until build() runs again or the suggester is reopened. build() handles the same failure with catch (Throwable), rollback() and writer = null (:362-368); ensureOpen() has no such handling. The same publish-before-install order lets a refresh() on another thread fail, transiently, during the first add() after every default build(). Present since 6.4.0 (LUCENE-7564, #8615); not fixed upstream. A one-method test reproduces it on one thread; a 17-line change to ensureOpen() fixes that, and adding a lock to refresh() also closes the concurrent windows.

The bug

  1. After a default build() (closeIndexWriterOnBuild=true, AnalyzingInfixSuggester.java:163) the state is: writer = null, searcherMgr = manager M1, opened on build()'s writer W1, which build() closed (:361, :370-373).
  2. refresh() (:489-498) takes no lock. It calls searcherMgr.maybeRefreshBlocking() only if (writer != null) (:493-494); otherwise it is a no-op, with the comment "writer was committed and closed after the index was built ... so refresh is unnecessary" (:496-497).
  3. The first add() after build() calls ensureOpen() (:418-434). Under writerLock it assigns the new writer W2 to the field (:423, or :427 for an empty directory) and only then calls setAndCloseOldSearcherManager(new SearcherManager(writer, null)) (:431), which installs M2 and closes M1.
  4. Between those two lines writer is W2 but searcherMgr is still M1. A refresh() there sees writer != null, refreshes M1, and M1 asks the closed W1 for changes (IndexWriter.nrtIsCurrent -> ensureOpen): AlreadyClosedException: this IndexWriter is closed.
  5. If new SearcherManager(writer, null) at :431 throws (it opens an NRT reader, which flushes and reads files), nothing resets writer. The add() throws, but writer = W2 stays next to M1. A retried add() skips the open (writer != null, :420) and writes into W2; every refresh() fails as in step 4. build() repairs it: it closes W2 and installs a new manager (:329-373), and, as always, recreates the index from its own input (OpenMode.CREATE, :338-340). build() itself handles the same failure (its own SearcherManager failing at :361) correctly: catch (Throwable t) { writer.rollback(); writer = null; throw t; } (:362-368).

Timeline (single thread, the minimal repro)

stepcallwritersearcherMgrresult
1build("lend me your ear")null (W1 closed)M1 on W1ok
2add("a penny saved")W2M1 on W1 (M2 failed to open)IOException
3add("a penny saved") againW2M1 on W1ok, written to W2
4refresh()W2M1 on W1AlreadyClosedException: this IndexWriter is closed
5refresh(), lookup("penn")W2M1 on W1AlreadyClosedException again; 0 results

Without the I/O error, step 2 installs M2 and closes the gap. A refresh() on another thread that runs while step 2 is opening M2 fails the same way once; a later refresh() works. build() has the same order (writer at :338, manager at :361), so a refresh() during a concurrent rebuild also fails (a contested trigger, see below).

BlendedInfixSuggester extends the class and overrides none of build(), ensureOpen(), add(), update() or refresh() (read; not executed).

Exposure and likelihood371 words
  • Default settings. closeIndexWriterOnBuild=true is the default of every constructor that does not take it (AnalyzingInfixSuggester.java:163, :244). The repro has one suggestion, so size does not matter. The class is @lucene.experimental (:95).
  • Effect. One I/O error in the first add() after a build() leaves the suggester stuck: the retried add() succeeds, every refresh() fails, and additions stay invisible until a rebuild or a reopen. An application that retries a failed add(), the natural reaction to an IOException, gets an exception that names a writer the suggester closed itself.
  • Concurrent form. A background thread that calls refresh() while another thread is inside the first add()/update() after build() (a window of one NRT reader open) gets the same exception once; a later refresh() works. A refresher thread that does not catch it stops, and suggestions go stale. A hook-free refresher loop hit it in 36 to 40 of 40 rounds in the independent reproducer's runs (33 and 37 of 40 in the adjudication's re-run of that test); a 60-round stress run counted 20,759 such exceptions.
  • What does not happen. No wrong or extra suggestion is returned: lookups serve the last published view. Nothing hangs or grows, and the index is not damaged: the addition sits in the open new writer, which commit() still reaches (:382-391); after commit() and close() a reopened suggester finds it. A later build() replaces the index with its own input, as every build() does.
  • Related forms (same root cause, executed by the fact check on main and 10.5.2). Without the retry, the refresh() right after the failed add() already throws. On a suggester opened over an existing index (no build() in that instance) the same one failure is silent: refresh() returns normally, but the addition stays invisible until commit(). Both fixes cover both forms.
  • Reach. Of the suggester's mutating API, Solr's suggest component calls only build(), never add(), update() or refresh(); no use of the class was found in Elasticsearch or OpenSearch (read-only checks). Exposed are applications that call the near-real-time API directly. The recorded searches found no public report, although the code dates from 6.4.0 (2017), so it is rated rare and low priority.

Minimal repro

One test method on the public API plus the test framework's MockDirectoryWrapper.failOn, which injects one I/O error while the first add() after build() opens its searcher manager. One thread, deterministic (10 of 10 randomized iterations fail), assertions on or off.

TestInfixSuggesterEnsureOpenFailure.java (one test method)58 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.util.Iterator;
import java.util.List;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.search.suggest.InputIterator;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestInfixSuggesterEnsureOpenFailure extends LuceneTestCase {

  public void testRefreshAfterFailedFirstAdd() throws Exception {
    MockDirectoryWrapper dir = newMockDirectory();
    try (AnalyzingInfixSuggester suggester =
        new AnalyzingInfixSuggester(dir, new StandardAnalyzer())) {
      Iterator<BytesRef> input = List.of(new BytesRef("lend me your ear")).iterator();
      suggester.build(
          new InputIterator.InputIteratorWrapper(() -> input.hasNext() ? input.next() : null));
      dir.failOn(
          new MockDirectoryWrapper.Failure() {
            boolean thrown;

            @Override
            public void eval(MockDirectoryWrapper d) throws IOException {
              if (thrown == false && callStackContains(SearcherManager.class, "<init>")) {
                thrown = true;
                throw new IOException("injected I/O error while opening the SearcherManager");
              }
            }
          });
      expectThrows(
          IOException.class, () -> suggester.add(new BytesRef("a penny saved"), null, 1, null));
      suggester.add(new BytesRef("a penny saved"), null, 1, null);
      // expected: refresh() makes the added suggestion visible; actual: AlreadyClosedException (this IndexWriter is closed)
      suggester.refresh();
      assertEquals(1, suggester.lookup("penn", 10, true, false).size());
    }
  }
}
Command
./gradlew :lucene:suggest:test --tests TestInfixSuggesterEnsureOpenFailure -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (fails the same way)
Expected
refresh() succeeds and lookup("penn") returns the added suggestion (1 result).
Observed
refresh() throws AlreadyClosedException: this IndexWriter is closed (AnalyzingInfixSuggester.refresh, line 494, via SearcherManager.refreshIfNeeded).

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

More files, environment, full output204 words

Save the file as lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestInfixSuggesterEnsureOpenFailure.java and run the command from the checkout root. The failure hook is installed after build() and fires once, inside the first add()'s ensureOpen(), when the call stack contains the SearcherManager constructor. MockDirectoryWrapper.failOn is the usual Lucene way to inject an I/O error; no specific production cause was observed. The final assertion states what refresh() should achieve; it passes with either fix below.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on the 10.6.0 release (this test) and on 6.4.0 to 10.5.2 (version-portable probe)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
./gradlew :lucene:suggest:test --tests TestInfixSuggesterEnsureOpenFailure -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (fails the same way)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (the same with -Ptests.asserts=false)
# (the module-path prefixes gradle prints before each class name are removed)
TestInfixSuggesterEnsureOpenFailure > testRefreshAfterFailedFirstAdd FAILED
    org.apache.lucene.store.AlreadyClosedException: this IndexWriter is closed
        at org.apache.lucene.index.IndexWriter.ensureOpen(IndexWriter.java:915)
        at org.apache.lucene.index.IndexWriter.ensureOpen(IndexWriter.java:928)
        at org.apache.lucene.index.IndexWriter.nrtIsCurrent(IndexWriter.java:6141)
        at org.apache.lucene.index.StandardDirectoryReader.doOpenFromWriter(StandardDirectoryReader.java:462)
        at org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:413)
        at org.apache.lucene.index.DirectoryReader.openIfChanged(DirectoryReader.java:284)
        at org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:200)
        at org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:53)
        at org.apache.lucene.search.ReferenceManager.doMaybeRefresh(ReferenceManager.java:167)
        at org.apache.lucene.search.ReferenceManager.maybeRefreshBlocking(ReferenceManager.java:240)
        at org.apache.lucene.search.suggest.analyzing.AnalyzingInfixSuggester.refresh(AnalyzingInfixSuggester.java:494)
        at org.apache.lucene.search.suggest.analyzing.TestInfixSuggesterEnsureOpenFailure.testRefreshAfterFailedFirstAdd(TestInfixSuggesterEnsureOpenFailure.java:54)
1 test completed, 1 failed

# with the recommended fix: the test passes, assertions on and off;
# :lucene:suggest:test -Ptests.asserts=true with the fix: 32 classes, 237 tests, 7 skipped, 0 failures

Why it looks unintended

  • refresh()'s own comment calls it a no-op once build() has closed its writer (lines 496-497), and upstream testRefreshAfterBuild expects no exception.
  • build() rolls back the same failure (lines 362-368); ensureOpen() does not, so a retried add() leaves the suggester stuck.
  • The repro is single-threaded and follows the add() javadoc: "you must call refresh in the end".
The full argument330 words
  • The code says refresh() is a no-op here. LUCENE-7564 added the writer != null gate and the comment "else no-op: writer was committed and closed after the index was built and before searchMgr was constructed, so refresh is unnecessary" (AnalyzingInfixSuggester.java:493-497). The exception comes from the publication order, not from a policy.
  • Upstream tests assert both halves. testRefreshAfterBuild (TestAnalyzingInfixSuggester.java:1586-1592) calls refresh() after build() for every option combination and expects no exception. testCloseIndexWriterOnBuild (:1501-1555) asserts that maybeRefreshBlocking() on the post-build SearcherManager throws AlreadyClosedException (:1553-1554): exactly the object refresh() reaches here.
  • The documented flow fails. add(): "After adding or updating a batch of new suggestions, you must call refresh in the end in order to see the suggestions in lookup" (AnalyzingInfixSuggester.java:437-439; the same for update(), :450-451). In the repro the batch is one successful add() and one refresh().
  • The class handles the same failure elsewhere. build() rolls back and nulls writer on any Throwable inside its try block, including its own SearcherManager failing (:361, :362-368); ensureOpen() leaves writer set without its manager, so no later add() repairs it (:420). Nothing documents that the suggester becomes unusable after an IOException from add(), and the writer underneath survives it: IndexWriter.addDocument says that after an exception "the index will be consistent, but this document may not have been added" (IndexWriter.java:1465-1466), and getReader() treats only an Error as tragic (IndexWriter.java:711-719), which is why the retried add() succeeds. No API promises that every IOException from add() can be retried.
  • For the concurrent form. The field javadocs say writerLock and searcherMgrLock are "Used to manage concurrent access" (:138, :147), and SearcherManager recommends "a separate background thread, that periodically calls maybeRefresh" (SearcherManager.java:45-48), advice about SearcherManager itself, which refresh() wraps: supporting context, not a contract of this class. No javadoc forbids refresh() while another thread adds. This contract is implied, not explicit.
The devil's-advocate check260 words

The strongest case for "intended": Lookup and this class document no thread safety; add()/update() say to call refresh() "in the end" of a batch; LUCENE-10292 (#11328, 9.2.0) promised only lookup() and getCount() during a concurrent build() (lucene/CHANGES.txt:4289-4291), and committers wrote there and on dev@ (2022-04-27) that Lookup.build() has no general concurrency guarantee; upstream testNRTWithParallelAdds joins its adders before it calls refresh() (TestAnalyzingInfixSuggester.java:982-1011). So refresh() overlapping add() or build() would be unsupported use. Read-only cross-model reviews (OpenAI GPT-5.6) argued this for the concurrent triggers; one wrote that its verdict flips to real if a background refresher is part of the near-real-time contract.

Why it fails: it covers only the concurrent triggers, and neither review saw the single-threaded one. The repro uses one thread and only the documented calls (build, add, retry the failed add, refresh); its failure is an exception-safety defect in ensureOpen(). The remaining objection there: no API promises that an add() can be retried after an IOException, and a maintainer may say that recovery means a rebuild or a reopen; the defect is then the inconsistent state the failure leaves (a live writer next to a manager on a closed writer) and the misleading exception, not a broken retry promise. For the background refresher during the first add() the argument is credible but weak, since it rests on silence that the class's locks and comments contradict. For refresh() during a rebuild it is strong, so that trigger is secondary and marked contested. Verdict: not intended, a defect (adjudication: confirmed, medium confidence).

Suggested fix

Recommended Publish the writer only with its manager, roll back on failure, and lock refresh()

In ensureOpen(), open the writer and its SearcherManager into locals, roll the writer back if the manager cannot be opened, and set writer only after the manager exists; also run refresh() under writerLock. Validated: the repro passes and all 237 suggest-module tests pass with assertions on. Cost: refresh() now waits for a running build().

All options and trade-offs4 options ยท 722 words

Two fixes were built and validated through gradle on main 645d4fe14a20 in a scratch worktree; the recommended one is an independent verifier's patch (+29/-10). The smaller one is its ensureOpen() half (+17/-3).

Publish the writer only with its manager, roll back on failure, and lock refresh() (validated by the checks) (recommended)

ensureOpen() builds the IndexWriter and the SearcherManager into locals; if the manager cannot be opened it rolls the new writer back (suppressing a second failure) and rethrows, so writer stays null and the next add() retries cleanly; otherwise it assigns writer and installs the manager. refresh() runs its unchanged body under writerLock, so it can no longer run between the two field writes of ensureOpen() or build(). +29/-10 in AnalyzingInfixSuggester.java.

private void ensureOpen() throws IOException {
  synchronized (writerLock) {
    if (writer == null) {
      final IndexWriter w = new IndexWriter(dir, ...); // APPEND or CREATE, as before
      final SearcherManager sm;
      try {
        sm = new SearcherManager(w, null);
      } catch (Throwable t) {
        try { w.rollback(); } catch (Throwable t2) { t.addSuppressed(t2); }
        throw t; // writer stays null, so the next add() retries cleanly
      }
      writer = w;                       // published only with its manager
      setAndCloseOldSearcherManager(sm);
    }
  }
}

public void refresh() throws IOException {
  synchronized (writerLock) {          // new: no refresh between the two field writes
    ... unchanged body ...
  }
}

Trade-offs

  • refresh() now waits for a whole concurrent build() (with it, the finder's race test took about 33 s, because refresh() waited until the test's bounded waits expired). Before 9.2.0 the same call threw IllegalStateException; waiting is arguably the better contract, but an application that calls refresh() on a request thread could stall for the length of a rebuild.
  • Every add()/update() enters writerLock in ensureOpen(), so an add() now also waits while a refresh() reopens the NRT reader.

What the checks validated

  • Gradle on main 645d4fe14a20, scratch worktrees: the repro fails on stock (assertions on and off) and passes with the fix (both). ./gradlew :lucene:suggest:test -Ptests.seed=DEADBEEF -Ptests.asserts=true with the fix and the new test: 32 classes, 237 tests, 7 skipped, 0 failures (TestAnalyzingInfixSuggester 23 of 23, TestBlendedInfixSuggester 9 of 9). ./gradlew :lucene:suggest:check -x test (google-java-format, ecj lint, forbidden APIs) passes once the repro's long comment line is wrapped; unwrapped, only that line is flagged. The assertion mode is recorded in the 2026-10-30 re-runs, whose logs start with their exact command.
  • Through the repro harness: the repro passes with the fix with -ea and -da on 645d4fe14a20. Earlier, on main ea57360912ed: the finder's race test (add path and rebuild path) and the single-threaded probe pass with it; the version-portable probe passes all three triggers; TestAnalyzingInfixSuggester and TestBlendedInfixSuggester 32 of 32.

Only the ensureOpen() half (smaller, no new blocking) (validated by the checks)

The same ensureOpen() change without the refresh() lock (+17/-3).

Trade-offs

Fixes the single-threaded trigger and narrows the add-path window from one NRT reader open to the two field writes in program order (writer = w, then the manager swap). Neither field is volatile and refresh() takes no lock, so there is no happens-before edge and that bound is not guaranteed: the window is narrowed, not formally closed. refresh() during a concurrent rebuild still fails, because build() must set writer before its loop (its own add() calls use the field).

What the checks validated

Gradle on main 645d4fe14a20: the repro passes with assertions on and off; :lucene:suggest:test -Ptests.seed=DEADBEEF -Ptests.asserts=true: 32 classes, 237 tests, 7 skipped, 0 failures. Repro harness: the finder's 3-test race class passes its add-path test and its control and still fails its refresh-during-rebuild test.

Only the refresh() lock (not recommended)

The finder's first patch: refresh() under writerLock, ensureOpen() unchanged.

Trade-offs

Closes the race windows but not the single-threaded trigger: after the failed add(), writer is still set next to the old manager, and the single-threaded probe still fails with it.

Untested variants

Make refresh() a no-op while a build() runs (a flag set under writerLock) instead of waiting for it; or read both fields under writerLock and call maybeRefreshBlocking() outside it, so add() does not wait for a refresh.

Trade-offs

Neither was built or run. In the second, a manager swapped out by a concurrent rebuild can be closed under the caller (this ReferenceManager is closed).

Downstream note

Of the suggester's mutating API, Solr's suggest component calls only build(), never add(), update() or refresh(), and no use of the class was found in Elasticsearch or OpenSearch, so only applications that call the suggester's near-real-time API directly can hit it.

How it was found

TLA+ model checking (TLC): a model of the suggester's build/add/refresh protocol, written from the code, broke its no-error invariant in a 7-state trace in which a refresher runs during the first add() after build(). A later verifier, asked to argue it was intended, found the single-threaded I/O-error trigger by reading ensureOpen() next to build()'s rollback.

The method in full251 words

Method: TLA+ model checking (TLC), in the hunt's suggester unit (main ea57360912ed).

  • The finder, an AI tool, wrote a TLA+ model of the build/add/refresh/lookup protocol from the code (InfixNRT.tla), with an invariant NoError: no call raises an exception. In configuration MC_conc (an adder thread and a refresher-plus-lookup thread, 2 documents, closeIndexWriterOnBuild=true) TLC reported a NoError violation in a 7-state trace (38 states generated, 27 distinct, about 1 s): the adder publishes the new writer, the refresher sees writer != null and refreshes the manager on the closed build writer. The sequential configuration, closeIndexWriterOnBuild=false and the variant with a locked refresh() are clean; the model starts after build() and has no rebuild step and no injected I/O error, so those clean runs say nothing about the rebuild and I/O-error forms.
  • The finder bridged the trace to a JUnit race test on the stock jar (an InfoStream hook holds the first add() inside the manager construction; 2 of 3 tests fail, 40 of 40 failing-test runs over 20 iterations), found the rebuild path by reading, and ran a hook-free stress run (20,759 exceptions in 60 rounds, no other invariant violation).
  • Verifier, an AI tool asked to make the strongest "intended" case, read ensureOpen() next to build()'s catch (Throwable) and found the single-threaded trigger: one injected I/O error in the SearcherManager construction. It showed that the finder's lock-only fix does not cover it and wrote the complete fix. The one-method repro on this page is that trigger, reduced.

Prior reports, history and checks

Prior reports

No matching public report in GitHub issues and PRs, JIRA, the dev@ and java-user@ lists or the Solr, Elasticsearch and OpenSearch trackers (2026-10-26, re-checked 2026-10-30; best effort). Closest: #8615 (LUCENE-7564), which created the window and fixed a different ensureOpen() race by locking every acquire() and the manager swaps, not refresh() or the publish order.

Searches4 searches

20 hits read and classified; none is this bug. Closest items, not reports:

  • #8615 / LUCENE-7564 (6.4.0): introduced closeIndexWriterOnBuild and the refresh() no-op branch, so it created the window. It was reopened for a different ensureOpen() race: lookup() acquiring the manager that the swap had just closed ("this ReferenceManager is closed"). That fix (93fdc20736d) put every SearcherManager acquire() (in lookup(), ramBytesUsed(), getChildResources() and getCount()) and the manager swaps under a new searcherMgrLock, and changed neither refresh() nor the publish order.
  • #11328 / LUCENE-10292 (9.2.0): lookup() during build(). Its fix keeps the old manager during build(), which creates the rebuild-path form; committers there and on dev@ (2022-04-27) said Lookup.build() has no general concurrency guarantee.
  • #10126 / LUCENE-9084 (a lock-order deadlock in ensureOpen()/build()) and #6951 / LUCENE-5889 (added ensureOpen(); a general 2014 remark that the writer "is not accessed in a way that expects to be used by multiple threads" names no defect).

Partial overlap: if the LUCENE-7564 lookup() race is read as "this bug class, fixed incompletely", the novelty label becomes known; the prior-art check and the adjudication judged it separate (different method, exception and lines; the word "refresh" does not occur in #8615 or its comments). GitHub full-text search is best effort; one prior-art run timed out on the search quota and was covered by manual searches.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, Lucene.NET)2026-10-26; re-checked 2026-10-30the scripted prior-report search (24 issue/PR and 4 commit queries) and about 15 manual read-only searches, e.g. AnalyzingInfixSuggester refresh, AnalyzingInfixSuggester AlreadyClosedException, ensureOpen SearcherManager, closeIndexWriterOnBuild; the newest AnalyzingInfixSuggester items and commitsno report (2026-10-30: the newest AnalyzingInfixSuggester issue, #16825, is a javadoc class name); no open PR touches refresh(), ensureOpen() or build() (only #16875, javadoc typos)
JIRA archive (LUCENE, SOLR)2026-10-268 queries, e.g. AnalyzingInfixSuggester AND refresh, ensureOpenthe related LUCENE-7564, LUCENE-10292, LUCENE-9084, LUCENE-5889, LUCENE-5477
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2624 queries; 3 threads read in fullno report; the dev@ 2022-04-27 thread on build() concurrency (context)
Downstream (Solr, Elasticsearch, OpenSearch) and the web (Stack Exchange)2026-10-264 downstream and 6 web queriesno report

History

Introduced in 6.4.0 by LUCENE-7564 (#8615, 2016-11-16), which made build() close its writer by default. A version-portable probe shows the single-threaded and add-path triggers on every executed release from 6.4.0 to 10.5.2 and on main, and the one-method test fails on 10.6.0; on 6.3.0 neither trigger can occur. Not fixed upstream.

Versions tested9 rows

Introduced by LUCENE-7564 (#8615), "AnalyzingInfixSuggester should close its IndexWriter by default at the end of build()", Steve Rowe, 2016-11-16: f9a0693bf98 on master (first release 7.0.0) and the branch_6x backport 4fedb640ab6 (first release 6.4.0), a JIRA patch without a pull request. It made build() close its writer while keeping the manager on it, added the refresh() gate, and so made the first add() after build() reopen a writer through ensureOpen(). The writer-then-manager order and the missing rollback are older (LUCENE-5889, 2014: 4a485d68771 on trunk, first released in 6.0.0, and e4731cf8a36 on branch_4x, first released in 5.0.0) but could not reach a closed writer before 6.4.0.

The rebuild-path form (refresh() during a concurrent build()) throws AlreadyClosedException since LUCENE-10292 (#11328), Chris Hostetter, 2022-04-08 (5015dc6dbb8, branch_9x b11b0714297, 9.2.0), which keeps the old manager during build(); before it the same call threw IllegalStateException: suggester was not built.

10.6.0's AnalyzingInfixSuggester.java differs from main only in build()'s exception handling and one javadoc typo, with the same ensureOpen() and refresh(). Releases between the executed ones are inferred affected from unchanged code; releases before 10.x ran on JDK 25 with API ports of the probe. AnalyzingInfixSuggester.java last changed on main in #16255 (2026-06-14, a build-check change).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-method repro fails (gradle and harness, assertions on and off)
main a9779ef3c193 affectedrace test 2 of 3 and single-threaded probe 1 of 2 fail (assertions on and off)
main ea57360912ed affectedprobe: all three triggers fail (assertions on and off)
10.6.0 affectedthe one-method repro fails (harness, assertions on and off); the diagram probe shows the same states as main
10.0.0, 10.5.2 affectedprobe: all three triggers fail (10.5.2 with assertions on and off)
9.2.0, 9.12.3 affectedprobe: all three triggers fail
6.4.0 to 9.1.0 (6.4.0, 6.4.1, 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.1.0) affectedprobe: the single-threaded trigger and the add-path race fail; refresh() during a rebuild throws IllegalStateException "suggester was not built" instead (6.4.0 with assertions on and off)
6.3.0 not affectedno window: build() keeps its writer open, so the first add() opens nothing and neither trigger can occur; the probe's rebuild test throws IllegalStateException "suggester was not built", as before 9.2.0
main + recommended fix not affectedthe repro and all three probe triggers pass

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and an adjudication judged it real; the adjudication re-ran all three repros with a new seed. Read-only cross-model reviews said "not real" for the concurrent triggers (one partly because it could not run a repro) and agreed with the root cause. Fact checks of this doc judged it real as described.

Checks and verdicts498 words

All checks ran on main ea57360912ed unless stated; the cross-model reviews only read code and could not run Java, and separate runs re-ran the finder's repro afterwards (fails). This doc's gradle validation ran on 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
Cross-model refute review not real (medium), procedural and contract groundsAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: the root cause agrees. Its verdict is partly procedural (it needed an executed independent repro) and partly the contract: no javadoc promises that refresh() may overlap add()/update()/build(); if that overlap is supported, "the source strongly suggests this should instead be classified as real". A separate run re-ran the finder repro: 2 of 3 fail.
Independent reproducer realOwn 5-test JUnit in another package: 4 of 5 fail on main (assertions on and off), 10.5.2, 9.12.3 and 8.11.4 (there the rebuild tests fail with IllegalStateException); a hook-free refresher loop threw in 36 to 40 of 40 rounds (33 and 37 in the adjudication's re-run), and every round recovered on a later refresh(); lookups work inside the window. It reads the contract as implied, not explicit.
Cross-model intended-behaviour review not real (medium), contract groundsThe same OpenAI GPT-5.6 reviewer with an intended-behaviour lens: root cause agrees; "flips to real if a background refresher counts as part of the NRT contract"; did not see the single-threaded trigger. A separate run re-ran the finder repro: fails on main and 10.5.2, passes with the finder's fix.
Intended-behaviour devil's advocate real; rebuild trigger contestedFound the single-threaded trigger (fails on main, 10.5.2 and 9.12.3, assertions on and off), showed that the finder's lock-only fix does not cover it, and wrote the complete fix (32 of 32 upstream suggester tests pass).
Adjudication confirmed (medium); wrong errorRe-ran the independent reproduction's test (4 of 5 fail), the finder repro (2 of 3) and the intended-behaviour check's probe (1 of 2) with seed 5EEDBA5E, assertions on and off; the finder repro and the intended-behaviour check's probe pass with the intended-behaviour check's fix.
Registration check, main refresh and reduction reproducesRe-runs on main ea57360912ed and a9779ef3c193 reproduce; the one-method repro fails on 645d4fe14a20 (assertions on and off, 10 of 10 randomized iterations under one root seed) and passes with the fix; this doc's gradle validation of two fixes.
Fact checks (two model families) and diagram check real as described; minor corrections appliedIndependent fact checks of this doc and its brief, one by an AI tool that re-ran the repro and the gradle validation and one by an independent reviewer from another model family (OpenAI GPT-5.6) reading the records, found the mechanism, repro, versions, fixes and security classification correct and listed minor wording and citation errors, all fixed here; the first also found the two related forms under "Who is affected". The diagram check passed. The fixes were re-run through gradle with the assertion mode recorded.

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

Finding record

LFV-263, snapshot of 2026-11-06

Registry title
AnalyzingInfixSuggester.ensureOpen() publishes its new IndexWriter before the SearcherManager opened on it and does not undo that when the manager cannot be opened, so refresh() refreshes the manager over build()'s closed writer and throws AlreadyClosedException
Area
suggest / analyzing infix suggester (AnalyzingInfixSuggester NRT add/update/refresh after build(); inherited unchanged by BlendedInfixSuggester [read])
Priority
Low, rank 632: AnalyzingInfixSuggester's direct NRT add and refresh API can throw AlreadyClosedException in a race window after build(), loud and rarely reached.
Impact class
wrong error
Impact note
Applications that call AnalyzingInfixSuggester's NRT add()/update()/refresh() API directly, with default settings (closeIndexWriterOnBuild=true) and at any size, on 6.4.0 through 10.6.0 or main, get AlreadyClosedException 'this IndexWriter is closed' from refresh(): transiently when a refresh() on another thread overlaps the first add()/update() after build() (a later refresh() succeeds, but a refresher thread that does not catch the exception stops and suggestions go stale), and persistently, on a single thread, after one I/O error while that first add() opens the NRT reader (the retried add() succeeds, but new suggestions stay invisible until build() runs again or the suggester is reopened); lookups keep serving the last published suggestions, nothing wrong is returned, the index is not damaged, and Solr's suggest component never calls add()/update()/refresh().
Summary
You build an AnalyzingInfixSuggester with the default closeIndexWriterOnBuild=true, add() a suggestion and call refresh(), and you expect the addition to become visible, as the add()/update() javadoc says, or at worst refresh() to be the no-op its own comment describes. The first add() after build() runs ensureOpen(), which assigns the writer field before it installs the SearcherManager opened on that writer, and refresh() (no lock) reads writer != null as 'the current manager is live', so in that gap it refreshes build()'s old manager, whose writer is closed, and throws AlreadyClosedException 'this IndexWriter is closed'. The gap is transient when refresh() runs on another thread during that first add() (or, contested, during a rebuild), and permanent after one IOException while ensureOpen() opens the manager: writer stays set, no later add() repairs it, and every refresh() fails until build() or a reopen.
Introduced
f9a0693bf98a4000b6568e7c63f3e303118470bd (sarowe, 2016-11-16, LUCENE-7564 / #8615 'AnalyzingInfixSuggester should close its IndexWriter by default at the end of build()', a JIRA patch with no pull request; branch_6x backport 4fedb640ab66920ea11c26ad520639912c95ff2c, the commit in 6.4.0) [exec: the version-portable history probe shows no window on 6.3.0 and fails on 6.4.0; read: git blame of the refresh() gate, the no-op comment and build()'s close names this commit]. It made build() close its writer by default while leaving the SearcherManager opened on that writer installed, added refresh()'s 'writer != null' gate, and so made the first add() after build() reopen a writer through ensureOpen(). The writer-before-SearcherManager order and the missing rollback in ensureOpen() are older (LUCENE-5889 / #6951, 4a485d68771 on trunk and e4731cf8a36, first released in 5.0.0) but could not reach a closed writer before 6.4.0. The rebuild-path symptom (AlreadyClosedException from refresh() during build(), instead of IllegalStateException 'suggester was not built') dates from 5015dc6dbb89a2d3f9c2cd0eb1674f6f146d09b4 (hossman, 2022-04-08, LUCENE-10292 / #11328; branch_9x b11b0714297ab5f8005aa01001824acc6ef488e7, 9.2.0), which keeps the old manager installed during build() (2016-11-16 (LUCENE-7564: the add path and the single-threaded I/O-error path); 2022-04-08 (LUCENE-10292: the AlreadyClosedException form of the contested rebuild path))
First affected version
6.4.0 [exec: the history probe fails on 6.4.0 with -ea and -da for the concurrent add path and the single-threaded I/O-error path; on 6.3.0, the last release without LUCENE-7564, build() keeps its writer open and neither window exists]; the rebuild-path AlreadyClosedException first appears in 9.2.0 [exec] (6.3.0 to 9.1.0 fail a refresh() during build() with IllegalStateException 'suggester was not built' instead)
Affected versions
6.4.0 through 10.6.0 and main ea57360912ed; not fixed upstream as of 2026-10-26 [exec: the one-method repro fails on the 10.6.0 release jars (-ea and -da); the follow-up's history probe fails for the add path and the I/O-error path on 6.4.0 (-ea and -da), 6.4.1, 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), and for the rebuild path from 9.2.0 on; the finder's race repro fails 2 of 3 on main (-ea and -da), 10.5.2, 9.12.3 and 8.11.4; the independent reproduction's own test fails 4 of 5 on main (-ea and -da), 10.5.2, 9.12.3 and 8.11.4 (on 8.11.4 the rebuild tests fail with IllegalStateException); the intended-behaviour check's single-threaded probe fails 1 of 2 on main (-ea and -da), 10.5.2 and 9.12.3; read: releases between the executed ones are inferred affected from unchanged code, and BlendedInfixSuggester inherits ensureOpen(), build() and refresh() unchanged]. Not affected: 6.3.0 and older [exec 6.3.0; read 5.x and older]. Caveats: releases before 10.x were run on JDK 25 with API ports of the probe; unreleased maintenance branches were not checked; BlendedInfixSuggester was not executed
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (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
formal: TLA+/TLC; also reached by code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record: 30 recorded query groups, namely the scripted prior-report search's final run with 24 GitHub issue/PR, 4 commit, 4 downstream (Elasticsearch, OpenSearch, Solr), 24 mailing-list (dev@, java-user@, issues@, commits@), 8 JIRA and 6 web queries and 610 distinct hits, a separate lists+JIRA run, the finder's and the intended-behaviour check's runs, about 15 manual read-only GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, OpenSearch, Elasticsearch and Lucene.NET, 8 JIRA queries over LUCENE and SOLR, 3 list threads read in full, Stack Exchange searches and git history; 20 hits read and classified, none the same bug; the finder, the independent reproduction, the intended-behaviour check and the adjudication found nothing either; re-check of the newest AnalyzingInfixSuggester items found nothing newer); not fixed upstream (the last commits to AnalyzingInfixSuggester.java are #16255 on main, 2026-06-14, and #16592 on branch_10x, 2026-09-05, both build changes; no open PR touches refresh(), ensureOpen() or build(); the only open PR on the file, #16875, fixes javadoc typos); closest items, not reports: #8615 / LUCENE-7564 (6.4.0; introduced closeIndexWriterOnBuild and the refresh() no-op branch; it was reopened for a different ensureOpen() race, lookup() acquiring the SearcherManager that the swap had closed, 'this ReferenceManager is closed', fixed by 93fdc20736d without touching refresh() or the publish order), #11328 / LUCENE-10292 (9.2.0; lookup() during build(); its fix creates the rebuild-path window, and committers there and on dev@ 2022-04-27 said Lookup.build() has no general concurrency guarantee), #10126 / LUCENE-9084 (8.5.0; a lock-order deadlock in ensureOpen()/build()), #6951 / LUCENE-5889 (added ensureOpen(); a general 2014 remark there about the writer's thread safety names no defect)
Lucene commit tested
main 645d4fe14a20 (2026-10-28) through gradle in a scratch worktree (removed afterwards): the one-method test on stock with assertions on and off (fails both ways) and with each of two fixes (passes); with each fix all of :lucene:suggest:test with assertions on (32 classes, 237 tests, 7 skipped, 0 failures); with the recommended fix also :lucene:suggest:check -x test. The same test through the repro harness on main 645d4fe14a20 (-ea, -da, 10 randomized iterations under one root seed, and with the recommended fix), and on the 10.6.0 release jars (-ea, -da). Earlier evidence ran on main ea57360912ed and a9779ef3c193 (byte-identical AnalyzingInfixSuggester.java), on 10.5.2, 9.12.3 and 8.11.4, and as a version-portable probe on 15 releases from 6.3.0 to 10.5.2 (6.3.0 has no window). 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 ea57360912ed) (@lucene.experimental), :132-151 (closeIndexWriterOnBuild; writer 'Used for ongoing NRT additions/updates'; writerLock 'Used to manage concurrent access to writer'; searcherMgr; searcherMgrLock 'Used to manage concurrent access to searcherMgr'; neither field volatile), :293-305 (setAndCloseOldSearcherManager under the searcherMgr write lock), :327-375 (build(): old writer closed at :330-333, writer assigned at :338, SearcherManager installed at :361, catch Throwable with rollback and writer = null at :362-368, writer closed when closeIndexWriterOnBuild at :370-373), :418-434 (ensureOpen(): writer assigned at :423/:427, SearcherManager installed at :431, no rollback), :436-459 (add()/update() javadoc 'After adding or updating a batch of new suggestions, you must call refresh in the end in order to see the suggestions in lookup'; ensureOpen() before addDocument/updateDocument), :485-498 (refresh(): no lock, 'writer != null' gate at :493, no-op comment at :496-497)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingInfixSuggester.java:184 (at ea57360912ed) (testLookupsDuringReBuild), :634 (testRandomNRT: add/update/refresh on one thread, lookups on another), :982 (testNRTWithParallelAdds joins its adders before refresh()), :1501-1555 (testCloseIndexWriterOnBuild: maybeRefreshBlocking() on the post-build SearcherManager throws AlreadyClosedException, :1554), :1586-1592 (testRefreshAfterBuild: refresh() after build() works for every option combination)
  • lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:45-48 (at ea57360912ed) (recommends a separate background thread that periodically calls maybeRefresh)
  • lucene/CHANGES.txt:4256-4258 (at ea57360912ed) (LUCENE-10292: lookup() and getCount() during concurrent build()), :8708 (LUCENE-7564)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:136, (at 645d4fe14a20) :145 (fields, not volatile), :163 (DEFAULT_CLOSE_INDEXWRITER_ON_BUILD = true), :293-305 (setAndCloseOldSearcherManager), :327-375 (build: :338, :361, :362-368, :370-373), :418-434 (ensureOpen: :420, :423, :427, :431), :489-498 (refresh)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/BlendedInfixSuggester.java (at 645d4fe14a20) (no override of build/ensureOpen/add/update/refresh)
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:95, (at 645d4fe14a20) :163, :244, :382-391
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:138, (at 645d4fe14a20) :147 (lock javadocs), :361, :362-368, :420, :437-439, :450-451, :493-497
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1465-1466 (at 645d4fe14a20) (addDocument javadoc), :711-719 (getReader(): only an Error is tragic)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingInfixSuggester.java:982-1011 (at 645d4fe14a20) (testNRTWithParallelAdds), :1501-1555 (testCloseIndexWriterOnBuild, :1553-1554), :1586-1592 (testRefreshAfterBuild)
  • lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:45-48 (at 645d4fe14a20)
  • lucene/CHANGES.txt:4289-4291 (at 645d4fe14a20) (LUCENE-10292, 9.2.0 section)
History
  • lucene/CHANGES.txt:8741-8742 (at 645d4fe14a20) (LUCENE-7564 in the 6.4.0 section), :4289-4291 (LUCENE-10292 in the 9.2.0 section)
  • a Lucene clone: git diff 645d4fe14a20 4ae946e5a9b9 -- AnalyzingInfixSuggester.java (10.6.0: build() success/finally structure and a javadoc typo; ensureOpen() and refresh() unchanged)

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

Repro files

About the diagrams

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.