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.
AnalyzingInfixSuggesterkeeps its suggestions in a small Lucene index:build()indexes a whole input,add()/update()add single suggestions later, andrefresh()makes those visible tolookup().- By default
build()commits and closes its writer at the end and setswriter = null, but keeps the searcher manager opened on that writer. Lookups keep working; refreshing that manager would fail, because its writer is closed, sorefresh()skips it whilewriteris null. - The first
add()afterbuild()callsensureOpen(), which opens a new writer and a new searcher manager on it, and swaps out the old manager.
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.- You build an
AnalyzingInfixSuggesterwith default settings from one suggestion,lend me your ear.build()closes itsIndexWriterat the end but keeps the searcher manager it opened on it, sorefresh()is now a no-op. - You call
add("a penny saved"). The first add afterbuild()opens a new writer and a new searcher manager. One I/O error while that manager opens its reader makesadd()throwIOException. You retry theadd(): it succeeds. - You call
refresh(), as theadd()javadoc asks, and expectlookup("penn")to return 1 suggestion. - Instead
refresh()throwsAlreadyClosedException: this IndexWriter is closed, and so does every laterrefresh();lookup("penn")returns 0 untilbuild()runs again or the suggester is reopened. The index is not damaged, andcommit()still saves the addition. - Why:
ensureOpen()sets thewriterfield (line 423) before it installs the new manager (line 431), and does not undo that when the install fails.refresh()readswriter != nullas "the manager is live" and refreshesbuild()'s old manager over the closed writer. A backgroundrefresh()during the firstadd()after abuild()hits the same gap briefly.
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.
- After a default
build()(closeIndexWriterOnBuild=true,AnalyzingInfixSuggester.java:163) the state is:writer = null,searcherMgr= manager M1, opened on build()'s writer W1, whichbuild()closed (:361,:370-373). refresh()(:489-498) takes no lock. It callssearcherMgr.maybeRefreshBlocking()onlyif (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).- The first
add()afterbuild()callsensureOpen()(:418-434). UnderwriterLockit assigns the new writer W2 to the field (:423, or:427for an empty directory) and only then callssetAndCloseOldSearcherManager(new SearcherManager(writer, null))(:431), which installs M2 and closes M1. - Between those two lines
writeris W2 butsearcherMgris still M1. Arefresh()there seeswriter != null, refreshes M1, and M1 asks the closed W1 for changes (IndexWriter.nrtIsCurrent->ensureOpen):AlreadyClosedException: this IndexWriter is closed. - If
new SearcherManager(writer, null)at:431throws (it opens an NRT reader, which flushes and reads files), nothing resetswriter. Theadd()throws, butwriter = W2stays next to M1. A retriedadd()skips the open (writer != null,:420) and writes into W2; everyrefresh()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 ownSearcherManagerfailing at:361) correctly:catch (Throwable t) { writer.rollback(); writer = null; throw t; }(:362-368).
| step | call | writer | searcherMgr | result |
|---|---|---|---|---|
| 1 | build("lend me your ear") | null (W1 closed) | M1 on W1 | ok |
| 2 | add("a penny saved") | W2 | M1 on W1 (M2 failed to open) | IOException |
| 3 | add("a penny saved") again | W2 | M1 on W1 | ok, written to W2 |
| 4 | refresh() | W2 | M1 on W1 | AlreadyClosedException: this IndexWriter is closed |
| 5 | refresh(), lookup("penn") | W2 | M1 on W1 | AlreadyClosedException 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=trueis 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 abuild()leaves the suggester stuck: the retriedadd()succeeds, everyrefresh()fails, and additions stay invisible until a rebuild or a reopen. An application that retries a failedadd(), the natural reaction to anIOException, 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 firstadd()/update()afterbuild()(a window of one NRT reader open) gets the same exception once; a laterrefresh()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); aftercommit()andclose()a reopened suggester finds it. A laterbuild()replaces the index with its own input, as everybuild()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 failedadd()already throws. On a suggester opened over an existing index (nobuild()in that instance) the same one failure is silent:refresh()returns normally, but the addition stays invisible untilcommit(). Both fixes cover both forms. - Reach. Of the suggester's mutating API, Solr's suggest component calls only
build(), neveradd(),update()orrefresh(); 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.
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.
/*
* 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());
}
}
}
./gradlew :lucene:suggest:test --tests TestInfixSuggesterEnsureOpenFailure -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (fails the same way)
- Expected
refresh()succeeds andlookup("penn")returns the added suggestion (1 result).- Observed
refresh()throwsAlreadyClosedException: this IndexWriter is closed(AnalyzingInfixSuggester.refresh, line 494, viaSearcherManager.refreshIfNeeded).
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.
| lucene | main 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) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
./gradlew :lucene:suggest:test --tests TestInfixSuggesterEnsureOpenFailure -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (fails the same way)
# 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 failuresrefresh()'s own comment calls it a no-op oncebuild()has closed its writer (lines 496-497), and upstreamtestRefreshAfterBuildexpects no exception.build()rolls back the same failure (lines 362-368);ensureOpen()does not, so a retriedadd()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 thewriter != nullgate 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) callsrefresh()afterbuild()for every option combination and expects no exception.testCloseIndexWriterOnBuild(:1501-1555) asserts thatmaybeRefreshBlocking()on the post-buildSearcherManagerthrowsAlreadyClosedException(:1553-1554): exactly the objectrefresh()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 forupdate(),:450-451). In the repro the batch is one successfuladd()and onerefresh(). - The class handles the same failure elsewhere.
build()rolls back and nullswriteron anyThrowableinside itstryblock, including its ownSearcherManagerfailing (:361,:362-368);ensureOpen()leaveswriterset without its manager, so no lateradd()repairs it (:420). Nothing documents that the suggester becomes unusable after anIOExceptionfromadd(), and the writer underneath survives it:IndexWriter.addDocumentsays that after an exception "the index will be consistent, but this document may not have been added" (IndexWriter.java:1465-1466), andgetReader()treats only anErroras tragic (IndexWriter.java:711-719), which is why the retriedadd()succeeds. No API promises that everyIOExceptionfromadd()can be retried. - For the concurrent form. The field javadocs say
writerLockandsearcherMgrLockare "Used to manage concurrent access" (:138,:147), andSearcherManagerrecommends "a separate background thread, that periodically calls maybeRefresh" (SearcherManager.java:45-48), advice aboutSearcherManageritself, whichrefresh()wraps: supporting context, not a contract of this class. No javadoc forbidsrefresh()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).
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 concurrentbuild()(with it, the finder's race test took about 33 s, becauserefresh()waited until the test's bounded waits expired). Before 9.2.0 the same call threwIllegalStateException; waiting is arguably the better contract, but an application that callsrefresh()on a request thread could stall for the length of a rebuild.- Every
add()/update()enterswriterLockinensureOpen(), so anadd()now also waits while arefresh()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=truewith the fix and the new test: 32 classes, 237 tests, 7 skipped, 0 failures (TestAnalyzingInfixSuggester23 of 23,TestBlendedInfixSuggester9 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
-eaand-daon645d4fe14a20. Earlier, on mainea57360912ed: 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;TestAnalyzingInfixSuggesterandTestBlendedInfixSuggester32 of 32.
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.
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.
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).
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.
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 seeswriter != nulland refreshes the manager on the closed build writer. The sequential configuration,closeIndexWriterOnBuild=falseand the variant with a lockedrefresh()are clean; the model starts afterbuild()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
InfoStreamhook holds the firstadd()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 tobuild()'scatch (Throwable)and found the single-threaded trigger: one injected I/O error in theSearcherManagerconstruction. 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.
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
closeIndexWriterOnBuildand therefresh()no-op branch, so it created the window. It was reopened for a differentensureOpen()race:lookup()acquiring the manager that the swap had just closed ("this ReferenceManager is closed"). That fix (93fdc20736d) put everySearcherManageracquire()(inlookup(),ramBytesUsed(),getChildResources()andgetCount()) and the manager swaps under a newsearcherMgrLock, and changed neitherrefresh()nor the publish order. - #11328 / LUCENE-10292 (9.2.0):
lookup()duringbuild(). Its fix keeps the old manager duringbuild(), which creates the rebuild-path form; committers there and on dev@ (2022-04-27) saidLookup.build()has no general concurrency guarantee. - #10126 / LUCENE-9084 (a lock-order deadlock in
ensureOpen()/build()) and #6951 / LUCENE-5889 (addedensureOpen(); 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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, Lucene.NET) | 2026-10-26; re-checked 2026-10-30 | the 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 commits | no 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-26 | 8 queries, e.g. AnalyzingInfixSuggester AND refresh, ensureOpen | the related LUCENE-7564, LUCENE-10292, LUCENE-9084, LUCENE-5889, LUCENE-5477 |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 24 queries; 3 threads read in full | no report; the dev@ 2022-04-27 thread on build() concurrency (context) |
| Downstream (Solr, Elasticsearch, OpenSearch) and the web (Stack Exchange) | 2026-10-26 | 4 downstream and 6 web queries | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-method repro fails (gradle and harness, assertions on and off) |
main a9779ef3c193 | affected | race test 2 of 3 and single-threaded probe 1 of 2 fail (assertions on and off) |
main ea57360912ed | affected | probe: all three triggers fail (assertions on and off) |
| 10.6.0 | affected | the one-method repro fails (harness, assertions on and off); the diagram probe shows the same states as main |
| 10.0.0, 10.5.2 | affected | probe: all three triggers fail (10.5.2 with assertions on and off) |
| 9.2.0, 9.12.3 | affected | probe: 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) | affected | probe: 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 affected | no 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 affected | the repro and all three probe triggers pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Cross-model refute review | not real (medium), procedural and contract grounds | An 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 | real | Own 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 grounds | The 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 contested | Found 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 error | Re-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 | reproduces | Re-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 applied | Independent 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 497 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,631 words
- 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:testwith 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 main645d4fe14a20(-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 mainea57360912edanda9779ef3c193(byte-identicalAnalyzingInfixSuggester.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.
- 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-497lucene/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)
- TestInfixSuggesterEnsureOpenFailure.java (one test method):
repro/LFV-263/src/org/apache/lucene/search/suggest/analyzing/TestInfixSuggesterEnsureOpenFailure.java(58 lines)
assets/LFV-263/bug.png(497 KB)
