One failed build, then a retry. The first indexDictionary throws (a bad encoding, an I/O error or a line over 32766 bytes); after the input is fixed, the retry should rebuild the spell index. It gets LockObtainFailedException, because the failed call's writer still holds write.lock.
SpellChecker(lucene-suggest) keeps its own small Lucene index of dictionary words, split into n-grams.indexDictionary(dict, config, fullMerge)adds the words of aDictionary(a word-list file, the terms of an index field, ...) to it, andclearIndex()empties it.- Each call opens an
IndexWriteron that spell index. Only one writer may be open per index: it takes the index'swrite.lockwhen it opens and gives it back only when it is closed or rolled back. - With the default
FSDirectory,NativeFSLockFactoryalso records each held lock path in a static set shared by the whole JVM, and refuses a second lock on the same path with "Lock held by this virtual machine".
write.lock; the exception skips writer.close(), the finally releases only the searcher, and the JVM-wide lock record then refuses the retry. Select the still to open it at full size. Generated with AI, checked against the code.- Build a spell index with the defaults:
new SpellChecker(FSDirectory.open(path)), thenindexDictionary(new PlainTextDictionary(words), new IndexWriterConfig(), false). The word list holdsalpha,bravo,café,delta, saved as ISO-8859-1, so readingcaféthrowsCharacterCodingException, anIOException. - Re-save the file as UTF-8 and call
indexDictionaryagain (orclearIndex()). You expect it to work: the failed call threw its documented exception and nothing else changed. - Instead you get
LockObtainFailedException: Lock held by this virtual machine: .../write.lock, and so does every later write to that spell index, even afterSpellChecker.close(),Directory.close()andSystem.gc(), until the JVM restarts. - Why:
indexDictionaryopens a localIndexWriter(line 496) and closes it only when the build succeeds (line 544); its onlyfinallyreleases the searcher. The caller has no handle on that writer, and the default lock factory keeps the lock path in a JVM-wide set until the lock is closed. - Reads keep working: suggestions keep coming from the last good build, and words the failed build added before it threw never become visible (0 of 20 in a probe). Nothing is corrupted; only writes are blocked.
Applications that use the classic n-gram SpellChecker with default settings and rebuild or clear its index in a long-running JVM after one build failed. No scale is needed: one badly encoded or over-long line is enough. Writes stay blocked until restart; reads keep serving the last good build. Rare: no diagnosed field report since 2008.
Mechanism, terms and numbers632 words
SpellChecker.indexDictionary (lucene-suggest) opens an IndexWriter on the spell index and closes it only when the build succeeds. When the build throws, from an I/O error while the dictionary is read, a word list that is not valid UTF-8, or a line over 32766 UTF-8 bytes, the writer is neither closed nor rolled back. It keeps the index's write.lock, the caller cannot reach it, and SpellChecker.close() does not close it. With the default FSDirectory, every later indexDictionary, clearIndex() or new IndexWriter on that path in the same JVM throws LockObtainFailedException: Lock held by this virtual machine until the JVM restarts. Suggestions keep coming from the last good build; nothing is corrupted. Every release from 2.4.0 (2008) to 10.6.0 and main is affected; rolling the writer back on failure fixes it.
// SpellChecker.indexDictionary, condensed
final IndexWriter writer = new IndexWriter(dir, config); // :496 takes write.lock
IndexSearcher indexSearcher = obtainSearcher(); // :497
... // :498-510
try { // :512
BytesRefIterator iter = dict.getEntryIterator(); // :513 may throw IOException
while ((currentTerm = iter.next()) != null) { // :517 may throw IOException
...
writer.addDocument(doc); // :535 may throw IllegalArgumentException
}
} finally {
releaseSearcher(indexSearcher); // :537-539 the only cleanup
}
if (fullMerge) writer.forceMerge(1); // :540-542
writer.close(); // :544 success path only- Line 496 opens the writer. With
FSDirectory,NativeFSLockFactoryputs the lock path into its staticLOCK_HELDset (NativeFSLockFactory.java:68,:108) and holds an OS file lock. - An exception from the dictionary at line 513 or 517, or the immense-term
IllegalArgumentExceptionat line 535, runs thefinally, which releases the searcher only, and leaves the method:writer.close()at line 544 is skipped, and nothing rolls the writer back.IndexWriterdoes not treat these exceptions as tragic, so it does not roll itself back either. The writer is a local variable, so it becomes unreachable with the lock still held. SpellChecker.close()(SpellChecker.java:639-647) closes only the searcher's reader; there is no API to reach the writer.- The next
new IndexWriteron that path (line 496 in a retry, line 456 inclearIndex(), or the application's own) finds the path inLOCK_HELDand throwsLockObtainFailedException("Lock held by this virtual machine: ...")(NativeFSLockFactory.java:119). Once the lock is held, only closing it removes the path (NativeFSLock.close(),:212->:145-146), and nothing closes it; GC does not remove the path either.
- A word list that is not valid UTF-8.
PlainTextDictionary(Path)decodes strictly as UTF-8 ("NOTE: content is treated as UTF-8",PlainTextDictionary.java:47-50):alpha,bravo,café,deltasaved as ISO-8859-1 throwsCharacterCodingExceptionatcafé. - An I/O error while the dictionary is read.
Dictionary.getEntryIterator()andBytesRefIterator.next()both declareIOException; the minimal repro uses this one. - A line over
IndexWriter.MAX_TERM_LENGTH= 32766 UTF-8 bytes (IndexWriter.java:258).addDocumentthrowsIllegalArgumentException"Document contains at least one immense term", as documented atIndexWriter.java:1480-1481(from 4.8.0 on).
After any of them, a retry with good input, clearIndex(), and a plain new IndexWriter on a freshly opened FSDirectory after SpellChecker.close(), Directory.close() and five System.gc() calls all throw LockObtainFailedException (executed on main with assertions on and off, 10.5.2 and 9.12.3).
Not every failure leaks. When the writer itself hits an unrecoverable error, IndexWriter treats it as tragic and rolls itself back, so the retry works (executed in the fact check): an I/O error on the spell index while addDocument writes a segment (DocumentsWriterPerThread.java:585-603, IndexWriter.java:1629-1641, 6090-6104; the immediate retry succeeds), and a failed merge inside forceMerge (IndexWriter.java:5108-5113; a retry 3 s later succeeds, an immediate one can still race the merge thread's rollback). The leak needs an exception the writer does not treat as tragic, as in the three triggers above.
Exposure and likelihood273 words
- Who. Users of the classic n-gram
SpellCheckerof lucene-suggest (notDirectSpellChecker, which has no spell index) that rebuild or clear the spell index without restarting the JVM. Defaults suffice:new SpellChecker(dir),new IndexWriterConfig(),FSDirectory.openwith its defaultNativeFSLockFactory. - When. One build fails once: an I/O error while the dictionary is read, a word list that is not valid UTF-8, a line over 32766 UTF-8 bytes, or a custom
Dictionarythat throws. Index-based dictionaries (LuceneDictionary,HighFrequencyDictionary) over text the index accepted cannot produce the over-long word (executed with 32766-byte terms). - What goes wrong. After the input is fixed, the spell index cannot be rebuilt or cleared again in that JVM: every write throws
LockObtainFailedExceptionuntil restart. If the application calledclearIndex()just before the failed build, that spellchecker returns no suggestions until the restart (by reading). - What it does not do (executed on main with assertions on and off, 10.5.2 and 9.12.3): no corruption and no data loss; the last good build keeps being served; none of the 20 words the failed build added is visible, even to a fresh
DirectoryReader(numDocs=3, the last commit); another spell index in the same JVM works; the source index is never written; the leak is bounded in the probe (5 to 10 file descriptors after the failed build, 0 more over 200 retries; the heap the leaked writer keeps was not measured). - How likely. Rare: it needs a failed build followed by an in-process rebuild, and no diagnosed field report exists since 2.4.0 (2008). One undiagnosed 2012 solr-user mail shows the same symptom from
clearIndex(see Prior reports).
One JUnit test, public API only: a Dictionary whose getEntryIterator() throws IOException, then a retry with a one-word PlainTextDictionary ("hello"). The retry should succeed; it throws LockObtainFailedException. The in-memory directory keeps the output to one failure; on disk the message is "Lock held by this virtual machine".
/*
* 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.spell;
import java.io.IOException;
import java.io.StringReader;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.LockObtainFailedException;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSpellCheckerFailedIndexDictionary extends LuceneTestCase {
public void testRetryAfterFailedIndexDictionary() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
SpellChecker spellChecker = new SpellChecker(dir)) {
Dictionary failing =
() -> {
throw new IOException("simulated read error");
};
expectThrows(
IOException.class,
() -> spellChecker.indexDictionary(failing, new IndexWriterConfig(), false));
try {
spellChecker.indexDictionary(
new PlainTextDictionary(new StringReader("hello\n")), new IndexWriterConfig(), false);
} catch (LockObtainFailedException e) {
throw new AssertionError(
"expected the retry to succeed, but the failed call's writer still holds the lock", e);
}
assertTrue(spellChecker.exist("hello"));
}
}
}
cp $REPRO/TestSpellCheckerFailedIndexDictionary.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/spell/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests org.apache.lucene.search.spell.TestSpellCheckerFailedIndexDictionary -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
- Expected
- The retry builds the spell index and
exist("hello")is true. - Observed
- The retry throws
LockObtainFailedException: lock instance already obtained ... lockName=write.lockfromSpellChecker.indexDictionary(SpellChecker.java:496)(assertions on and off).
More files, environment, full output238 words
One test class, one method, single thread, nothing random that matters. The failing Dictionary lands in the same try (SpellChecker.java:512-539) as a failing word-list reader. Each call gets a new IndexWriterConfig, as IndexWriter requires.
It uses ByteBuffersDirectory, whose SingleInstanceLockFactory reports "lock instance already obtained"; with an on-disk FSDirectory the test framework would also report the leaked write.lock handle as a second, suite-level failure. With FSDirectory.open(path) the retry's message is "Lock held by this virtual machine: <path>/write.lock", and the lock survives SpellChecker.close(), Directory.close(), System.gc() and a reopened FSDirectory (executed by the independent checks; not a second repro here).
This same test is the suggested regression test: it fails on stock main and passes with the fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT) and the 10.6.0 release: fails with assertions on and off; main with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (module prefixes and environment notes cut)
TestSpellCheckerFailedIndexDictionary > testRetryAfterFailedIndexDictionary FAILED
java.lang.AssertionError: expected the retry to succeed, but the failed call's writer still holds the lock
at __randomizedtesting.SeedInfo.seed([DEADBEEF:BA04F15DCE3F9F8B]:0)
at org.apache.lucene.search.spell.TestSpellCheckerFailedIndexDictionary.testRetryAfterFailedIndexDictionary(TestSpellCheckerFailedIndexDictionary.java:43)
Caused by:
org.apache.lucene.store.LockObtainFailedException: lock instance already obtained: (dir=ByteBuffersDirectory@49739f33 lockFactory=org.apache.lucene.store.SingleInstanceLockFactory@74a4c501, lockName=write.lock)
at org.apache.lucene.store.SingleInstanceLockFactory.obtainLock(SingleInstanceLockFactory.java:39)
at org.apache.lucene.store.BaseDirectory.obtainLock(BaseDirectory.java:44)
at org.apache.lucene.index.IndexWriter.<init>(IndexWriter.java:954)
at org.apache.lucene.search.spell.SpellChecker.indexDictionary(SpellChecker.java:496)
at org.apache.lucene.search.spell.TestSpellCheckerFailedIndexDictionary.testRetryAfterFailedIndexDictionary(TestSpellCheckerFailedIndexDictionary.java:40)
1 test completed, 1 failed
# -Ptests.asserts=false: the same failure and message, 1 test, 1 failure
# with the fix: 1 test, 0 failures (the retry builds and exist("hello") is true)indexDictionarydocuments@throws IOException, so failure is expected; nothing says the index stays locked afterwards, and the caller has no handle to release the writer.AnalyzingInfixSuggester.build, in the same module, rolls back its writer on anyThrowable.- Up to 2.3.2 a retry worked (a force-unlock); LUCENE-1152 removed it in 2008 without discussing failed builds.
The full argument186 words
- The contract.
indexDictionary: "@throws IOException If there is a low-level I/O error" (SpellChecker.java:489). Nothing documents that the spell index stays locked afterwards, and the writer is a local variable, so only the method can release it. - The module's own idiom.
AnalyzingInfixSuggester.build:catch (Throwable t) { if (writer != null) { writer.rollback(); writer = null; } throw t; }(AnalyzingInfixSuggester.java:362-368);PlainTextDictionary.FileIterator.nextcloses its reader on anyThrowable(PlainTextDictionary.java:92-95). - History. Up to 2.3.2 every
indexDictionaryforce-unlocked the index first, so a retry after a failed build worked (executed: 1.9.1 to 2.3.2 pass). LUCENE-1138 (#2215) objected that SpellChecker "shouldn't be calling unlock unless it has reason to think there is a 'stale lock'" (hossman), and LUCENE-1152 (#2229) removed the unlock in a clean-up; neither thread discusses a failed build. The latertry/finally(LUCENE-2391, #3466, 2011) covered only the searcher. - Upstream precedent. LUCENE-2476 (#3550, "Constructor of IndexWriter let's runtime exceptions pop up, while keeping the writeLock obtained") was a
type:bug, Blocker, fixed in 2.9.3, 3.0.2, 3.1 and 4.0. - Tests.
TestSpellCheckernever makes a build fail; no test asserts the current behaviour.
The devil's-advocate check154 words
The strongest "intended or not worth it" case (the devil's-advocate verifier and the intended-lens review): the javadoc promises only @throws IOException and says nothing about the state afterwards; PlainTextDictionary says "content is treated as UTF-8", so a Latin-1 file is misuse, and a 32766-byte "word" is bad input; SpellChecker is an old class with DirectSpellChecker as the newer alternative; a restart fixes it.
Why it fails: the caller has no handle on the writer, so no correct usage can release the lock on that path (the application can only move the SpellChecker to another directory with setSpellIndex, or restart); even where the input is misuse, the documented result is the exception, not a lock that outlives the fixed input; the I/O-error trigger involves no misuse at all; the class is not deprecated, experimental or internal; and the same module rolls back in the identical situation. The cross-model lenses rated the intended argument weak.
Recommended A. Roll the writer back on any failure and rethrow the original exception
Wrap everything between new IndexWriter(...) and writer.close() in try { ... } catch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, writer::rollback); throw t; }, the pattern AnalyzingInfixSuggester.build already uses. Rollback releases the lock and keeps the last good build. Validated: the repro passes with assertions on and off, and the suggest module's tests pass.
final IndexWriter writer = new IndexWriter(dir, config);
+ try {
IndexSearcher indexSearcher = obtainSearcher();
... (body unchanged, indented two more spaces) ...
if (fullMerge) {
writer.forceMerge(1);
}
+ } catch (Throwable t) {
+ // the build failed: roll back so the write lock is released and the last good build stays
+ IOUtils.closeWhileSuppressingExceptions(t, writer::rollback);
+ throw t;
+ }
// close writer
writer.close();All options and trade-offs3 options · 560 words
Option A was run through gradle in a scratch worktree of 645d4fe14a20. B was not run; C is rejected. All of them change only the failure path: a successful build behaves as before.
A. Roll the writer back on any failure and rethrow the original exception (validated by the checks) (recommended)
In SpellChecker.indexDictionary (SpellChecker.java:496-544), plus import org.apache.lucene.util.IOUtils;: 41 lines added and 34 removed, almost all re-indentation; ignoring whitespace, the change is the try { line, the five-line catch and the import.
final IndexWriter writer = new IndexWriter(dir, config);
+ try {
IndexSearcher indexSearcher = obtainSearcher();
... (body unchanged, indented two more spaces) ...
if (fullMerge) {
writer.forceMerge(1);
}
+ } catch (Throwable t) {
+ // the build failed: roll back so the write lock is released and the last good build stays
+ IOUtils.closeWhileSuppressingExceptions(t, writer::rollback);
+ throw t;
+ }
// close writer
writer.close();Trade-offs
IndexWriter.rollback()closes the writer without committing (IndexWriter.java:2566-2576): the lock is released and the spell index stays at its last commit, which is what readers already see today, so nothing partial becomes visible.- The original exception is rethrown, with a rollback failure attached as suppressed (
IOUtils.java:104-137). By that helper's design, if the build threw something other than anErrorand the rollback throws anError, thatErroris thrown instead, with the original attached as suppressed.AnalyzingInfixSuggester.buildcallswriter.rollback()directly, which would lose the original exception if the rollback also threw. - The
trystarts right after the writer opens, so it also coversobtainSearcher(), theterms()setup andforceMerge(by reading). If theterms()setup throws, the writer is rolled back but, as today, the searcher is not released, because the innerfinallystarts only at line 512; moving that setup inside the innertrywould cover it too.writer.close()stays outside: whenclose()fails,IndexWriteralready rolls back itself (with the defaultcommitOnClosethroughshutdown(),IndexWriter.java:1307-1338; withcommitOnClose=falseclose()just callsrollback(),:1363-1369). - Backports:
IOUtils.closeWhileSuppressingExceptions(Throwable, ...)exists from 10.4.0 on; on older branches useIOUtils.closeWhileHandlingException(writer::rollback)or theAnalyzingInfixSuggestershape.
What the checks validated
- Stock
645d4fe14a20: the repro fails with assertions on and off. - With A: the repro and
TestSpellCheckerpass with assertions on and off (2 suites, 7 tests, 0 failures); the whole:lucene:suggest:testpasses with assertions on (32 suites, 237 tests, 0 failures, 7 skipped). checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:suggestpass, with the repro file as it is.- Earlier, the finder's equivalent fix passed the finder's, the independent reproducer's, the intended-lens review's and the devil's-advocate verifier's tests on main
ea57360912ed, and the minimal repro on645d4fe14a20.
Not run. Releases the lock too.
Trade-offs
close()commits by default, so the words added before the failure would become visible as a half-built spell index, unlike today, where readers keep the last good build.
The JVM-wide lock record and the OS file lock belong to the leaked writer, and there is no public API to drop them; force-unlocking is also what LUCENE-1138 objected to (SpellChecker "shouldn't be calling unlock unless it has reason to think there is a 'stale lock'"), and by our reading it could break a lock that another writer legitimately holds.
By reading only (nothing run downstream), Solr's IndexBasedSpellChecker and FileBasedSpellChecker call clearIndex() and then indexDictionary(), so there one failed rebuild would leave that spellchecker with no suggestions and unable to rebuild until the restart.
A static exception-path resource scan: a bytecode abstract interpreter with explicit exception edges, run over Lucene's main jars, flagged the IndexWriter opened at line 496 as still open on the exception edges of the dictionary iterator. The finder confirmed it by reading and with a public-API JUnit test.
The method in full141 words
Method: a custom bytecode abstract interpretation (semi-formal in this hunt's tiers). The tool tracks a live set of acquired resources along normal and exception edges, with interprocedural summaries. It ran over the stock main jars of core, codecs, facet, suggest, misc and replicator (3,388 classes, 24,094 methods). Vacuity checks: all 7 planted leaks flagged, none of 8 clean idioms flagged.
- It reported the
IndexWriteracquired atSpellChecker.java:496as leaked on the exception edges ofDictionary.getEntryIterator(:513),BytesRefIterator.next(:517) andreleaseSearcher(:538). - The same unit's fault-injection sweep of the directory (3,753 single-fault runs) could not reach it: here the dictionary fails, not the directory.
- The finder triaged the finding by reading and confirmed it with a JUnit test on the stock jar; independent verifiers then reproduced it without the finder's code and added the non-UTF-8 trigger.
No prior report: GitHub issues and PRs, the JIRA archive (LUCENE, SOLR), the dev@, java-user@ and solr-user@ lists and the web (about 190 queries, 2026-10-26; refreshed 2026-11-01). Nearest: SOLR-4399 and an undiagnosed 2012 solr-user mail with the same symptom.
Searches4 searches
Verdict new. No issue, PR, commit, JIRA ticket, list thread or downstream issue reports that indexDictionary leaves its writer and lock behind on failure. SOLR-4399 (open, no comments) is an NPE escaping indexDictionary after the writer opened, reported only as Solr's missing parameter check. The 2012 solr-user mail "Spellchecker problem" (no replies) shows clearIndex failing with "Lock obtain timed out"; it was never diagnosed and other causes fit, so it is at most possibly related.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, global) | 2026-10-26; refreshed 2026-11-01 | two scripted prior-report search runs (96 queries) and 42 GraphQL searches: SpellChecker, indexDictionary, clearIndex, write.lock, rollback, immense term, "Lock obtain timed out"; refresh: "SpellChecker indexDictionary", "write.lock spellchecker", PRs mentioning SpellChecker updated since 2026-10-26 | no report; #3184 / LUCENE-2108 (searcher descriptors), #2229 / LUCENE-1152 (removed the force-unlock), #2215 / LUCENE-1138, #3466 / LUCENE-2391: different defects; refresh: only #5553, #9020, #2537, #1707 (javadoc and old API issues) and #16957 (an analyzer PR) |
| Open PRs by touched file | 2026-10-26 | the changed files of all 407 open apache/lucene PRs | #13951 and #16847 touch SpellChecker.java but leave indexDictionary unchanged |
| JIRA archive (LUCENE, SOLR) | 2026-10-26 | 12 JQL queries; full reads of SOLR-2654, SOLR-4399, SOLR-4408, SOLR-1877, LUCENE-1413, LUCENE-2386 | SOLR-4399 nearest (an NPE escaping indexDictionary; no mention of the writer or lock) |
| Mailing lists (lists.apache.org) and the web | 2026-10-26 | 8 direct list searches (all 19 dev@ hits for "SpellChecker write.lock" read) plus the scripted search's list queries; 12 web searches | the 2012 solr-user "Spellchecker problem" mail, undiagnosed; nothing else |
The lock-out came with LUCENE-1152 (a379a678759, 2008-05-22), which removed the force-unlock that let a retry work; first released in 2.4.0. 2.3.2 and earlier pass, 2.4.0 fails, and the commit itself is pinned; 22 releases from 2.4.0 to 10.5.2, 10.6.0 and main fail.
Versions tested7 rows
Introduced by a379a6787596 "LUCENE-1152 Fix for calling indexDictionary after clearIndex call" (svn r659013, 2008-05-22, committed by Otis Gospodnetic; CHANGES.txt credits Naveen Belkale). It removed the IndexReader.unlock(spellIndex) that indexDictionary and clearIndex ran before opening their writers. The missing close or rollback on the failure path is older: indexDictionary has reached writer.close() only on success since the spellchecker's first commit, 57cd0765650 (David Spencer, 2004-11-02); later rewrites (LUCENE-2108, LUCENE-2391, LUCENE-3557) kept that.
Recovery by GC changed over time: in 2.4.x IndexWriter.finalize still released the lock; from 2.9.0 (LUCENE-1715 removed the finalizer) the lock survives GC; 4.8.0 is the one exception on JDK 25 (LUCENE-5612 had dropped the JVM-wide set, LUCENE-5738 restored it in 4.9.0). The immediate retry fails in every affected version. The over-long-word trigger exists only from 4.8.0 (LUCENE-5472).
| Version or commit | Verdict | Result |
|---|---|---|
| 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.3.1, 2.3.2 | not affected | the writer stays open too, but the retry force-unlocks the index and works |
2.4.0 + spell package at a379a678759^ vs at a379a678759 | commit pin | the parent's retry works; the commit's fails (SpellChecker.java is the only differing file) |
| 2.4.0, 2.4.1 | affected | the retry fails ("Lock obtain timed out"); GC still frees the lock (finalizer) |
| 2.9.0, 2.9.4, 3.0.3, 3.4.0, 3.5.0, 3.6.2, 4.0.0, 4.7.2, 4.8.0, 4.9.0, 4.10.4, 5.0.0 | affected | the retry fails; from 2.9.0 the lock survives GC (except 4.8.0 on JDK 25) |
| 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 | affected | the retry fails: "Lock held by this virtual machine" |
| 10.6.0 | affected | the minimal test fails with assertions on and off (release jars) |
main 645d4fe14a20 | affected | the minimal test fails through gradle with assertions on and off; passes with the fix |
Confirmed with high confidence: the finder, an independent reproducer, reviewers from another model family, a devil's-advocate verifier and an adjudication all found it real; every executed check reproduced it, and the fix makes every test pass.
Checks and verdicts428 words
Everyone agrees on the root cause; the class was lowered from "index unusable" to crash because reads keep working.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit (failing reader; a 40,000-character word) fails on main (assertions on and off), 10.5.2 and 9.12.3; passes with its fix; TestSpellChecker 6/6 with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | real, high | Argued that "index unusable" was overstated (reads work); the class was then set to crash. Asked the fix to keep the original exception. A later refute run produced no verdict. |
| independent reproducer | real | Own JUnit from the claim alone, FSDirectory, mock file systems off: 3 of 4 fail on main, 10.5.2 and 9.12.3 (assertions on and off), including a plain new IndexWriter after close, reopen and 5x GC; the control passes; its own rollback fix passes 4/4. |
| cross-model review, intended lens (OpenAI GPT-5.6, read-only) | real, medium | Intended argument weak, inputs in contract; a separate probe shows the lock surviving SpellChecker.close() and GC; 4/4 pass with the fix. |
| devil's-advocate verifier | real, high on intent | Added the non-UTF-8 word-list trigger: fails on main (assertions on and off), 10.5.2 and 9.12.3; passes with the fix. |
| adjudication | confirmed, high | Re-ran the reproducer's test with a new seed (3 of 4 fail, control passes; 4/4 pass with the fix). |
| history, security screen, audit, minimization | 2.4.0 first affected; security none; confirmed; verified | 29 releases and main, with a commit-level pin; a bounded-leak and read-safety probe; a plain main() without the test framework fails on main and 10.5.2; the one-test repro fails 10/10 on 645d4fe14a20. |
| this doc (2026-11-01) | reproduced, fix validated | Gradle on 645d4fe14a20: the minimal test fails with assertions on and off; with the fix it passes with TestSpellChecker, and the whole :lucene:suggest:test passes with assertions on (32 suites, 237 tests, 0 failures, 7 skipped); the 10.6.0 release jars fail too. |
| fact check of this doc (2026-11-01) | bug stands; 4 text errors fixed | Re-ran the repro (standalone and through gradle, assertions on and off, main and 10.6.0) and the fix; its own FSDirectory probe confirmed the three triggers, the lock surviving close, reopen and GC, and the reads. A second probe showed that the writer's own unrecoverable errors make IndexWriter roll itself back (see What happens). |
| cross-model fact check (OpenAI GPT-5.6, read-only) | substantially sound | Four wording errors (when a rollback Error wins, commitOnClose=false, fix B and fullMerge, the second cleanup path of the held-lock set), fixed here. It did not execute anything. |
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 513 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,678 words
- Registry title
- SpellChecker.indexDictionary never closes or rolls back its IndexWriter when the build throws, so the spell index stays write-locked and every later indexDictionary, clearIndex or IndexWriter on it in the same JVM fails with LockObtainFailedException until the JVM restarts
- Area
- suggest / spell: SpellChecker.indexDictionary (n-gram spell index build, failure cleanup)
- Priority
- Low, rank 1081: A failed SpellChecker.indexDictionary leaves the write lock held until JVM restart and can be triggered by an ordinary badly encoded word list.
- Impact class
- crash
- Impact note
- Applications of the classic n-gram SpellChecker of lucene-suggest (default constructor, default IndexWriterConfig and FSDirectory with its default NativeFSLockFactory suffice) whose indexDictionary build throws once, from an I/O error while the dictionary is read, a PlainTextDictionary word list that is not valid UTF-8 (CharacterCodingException) or a word-list line over 32766 UTF-8 bytes (IllegalArgumentException 'immense term', from 4.8.0 on), and that then rebuild or clear that spell index in the same JVM get LockObtainFailedException 'Lock held by this virtual machine' on every write to it (indexDictionary, clearIndex, a new IndexWriter on the path, even after SpellChecker.close(), Directory.close() and System.gc()) until the JVM restarts; suggestions keep coming from the last good build (none if clearIndex had already emptied the index in that cycle, code reading), nothing is lost or corrupted, other indexes are unaffected, 200 retries leak nothing more [exec], LuceneDictionary and HighFrequencyDictionary cannot produce the over-long term, and no scale is needed.
- Summary
- You build a spell index with SpellChecker.indexDictionary and the build throws, for example because the word-list file is not valid UTF-8, has a line longer than 32766 bytes, or its reader hits an I/O error; you fix the input and call indexDictionary or clearIndex again. You expect the retry to work, because the failed call should close or roll back the IndexWriter it opened. Instead indexDictionary closes its writer only on the success path, so the writer, which the caller cannot reach, keeps the spell index's write lock, and every later write to that index in the same JVM throws LockObtainFailedException 'Lock held by this virtual machine' until the JVM restarts.
- Introduced
- a379a6787596e22f5f41b8515f6053be59ae51e5 (svn trunk r659013, 2008-05-22, 'LUCENE-1152 Fix for calling indexDictionary after clearIndex call', patch by Naveen Belkale via Otis Gospodnetic; JIRA LUCENE-1152 / apache/lucene#2229, no PR, it predates GitHub) [exec: commit-level pin: over the same 2.4.0 core jar the spell package at a379a678759^ (2175d84d641) does not reproduce and at a379a678759 it does; SpellChecker.java is the only file that differs between the two trees]. That commit removed the force-unlock ('if (IndexReader.isLocked(spellIndex)) IndexReader.unlock(spellIndex);') that indexDictionary ran before opening its writer, so from then on a writer left open by a failed build blocks every later writer. The missing close or rollback on the failure path itself is older: since the first spellchecker commit 57cd0765650d0f73db37e090eaed4748fcd2fc82 (svn r151014, 2004-11-02, 'prelim checking of spellchecker, v1.1') indexDictionary reaches writer.close() only on success [read: git log --follow, git log -S 'unlock(', git blame -w -M -C -C]. Later rewrites never closed the writer on failure: LUCENE-2108 ab447b6af06 (2009), LUCENE-2391 c26fd7564a5 (2011, today's try/finally, which releases only the searcher), LUCENE-3557 369a07a196e (2011), LUCENE-4246 8559eafd99e (2014) [read]. (2008-05-22 (a379a678759, the lock-out); the missing close or rollback on the failure path dates from 2004-11-02 (57cd0765650))
- First affected version
- 2.4.0 (tag 2008-10-08) [exec]; 2.3.2 (2008-05-06) and every earlier release with the spellchecker (1.9.1-2.3.2) are not affected [exec]: the writer also stays open there, but the next indexDictionary force-unlocks the index, so the retry works. In 2.4.0 and 2.4.1 System.gc() + runFinalization() still release the lock (IndexWriter.finalize); from 2.9.0 on (LUCENE-1715 removed the finalizer) the lock survives garbage collection, except 4.8.0 on the JDK 25 runtime used here (LUCENE-5612 had dropped the JVM-wide held-lock set; LUCENE-5738 restored it in 4.9.0) [exec]; the immediate retry fails in all of them. The immense-term trigger exists only from 4.8.0 (LUCENE-5472); in 4.7.2 and earlier such a word was skipped silently [exec]
- Affected versions
- Every release from 2.4.0 to 10.6.0, plus main ea57360912ed. [exec] a public-API main() history probe (one source per API era; scenarios: control, PlainTextDictionary over an InputStream that fails after one word, a 40000-character word, and for 1.9-2.4 a Dictionary whose iterator throws; then a rebuild with a good dictionary) reproduces on 22 releases: 2.4.0, 2.4.1, 2.9.0, 2.9.4, 3.0.3, 3.4.0, 3.5.0, 3.6.2, 4.0.0, 4.7.2, 4.8.0, 4.9.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (-ea; also -da on 9.12.3 and 10.5.2) and on main (-ea and -da), and passes on main with the fix shadowed; not affected on 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.3.1 and 2.3.2 (retry works through the force-unlock). The finder's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's on main, 10.5.2 and 9.12.3 (-ea and -da each); the intended-behaviour check's (non-UTF-8 word list) on main (-ea and -da), 10.5.2 and 9.12.3; a cross-model review re-ran the finder's JUnit on main (-ea and -da) and 10.5.2 (seed 1234ABCD); the JUnit tests need the 9.x+ test framework, so 8.x ran only through the main() probe. [read] a scan of the 144 release tags that ship SpellChecker.java finds no 'unlock(' from 2.4.0 on and no 'rollback' in any tag, and in every tag from 3.1 on the only finally in indexDictionary releases the searcher; upstream origin/main a9779ef3c19, branch_10x and branch_10_6 have the same indexDictionary as ea57360912ed and 10.5.2 (javadoc markup only differs; 2026-10-26). The one-test minimal repro fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da, and through gradle on main 645d4fe14a20 with assertions on and off (2026-11-01). Not executed: the other point releases (same structure by the tag scan), x86, other lock factories.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal
- Upstream and prior reports
- none found (searched 2026-10-26, read-only): about 190 query records: two scripted prior-report searches (96 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 1104 distinct hits in the second, its one 'high' a term-overlap artefact), 42 GitHub GraphQL searches (apache/lucene, apache/lucene-solr, apache/solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, global), 12 JQL queries (LUCENE and SOLR), 8 lists.apache.org searches (dev@ 'SpellChecker write.lock': all 19 hits read), 12 web searches, the bodies and comments of 24 GitHub and 6 JIRA issues and the list threads behind the hits, a scan of the changed files of all 407 open apache/lucene PRs and git history. Not fixed upstream: the newest commit on SpellChecker.java upstream is 378ca8d1bbf (2026-01-15), older than ea57360912ed; origin/main a9779ef3c19 (2026-10-26) has no change in the spell package since; the only open PRs touching SpellChecker.java (#13951, #16847) leave indexDictionary unchanged. Nearest items, not reports of this bug: SOLR-4399 (open since 2013, no comments: an NPE escaping indexDictionary after the writer is opened; about Solr's missing parameter, it never mentions the writer or lock); a 2012 solr-user mail 'Spellchecker problem' (no replies: a later spellcheck.build fails in SpellChecker.clearIndex with 'Lock obtain timed out' and the spellchecker directory is almost empty; undiagnosed, other causes fit, at most possibly related); #3184 / LUCENE-2108 (the searcher's file descriptors, added close()); #2229 / LUCENE-1152 (the commit that removed the force-unlock; its comments never discuss a failed build); #2215 / LUCENE-1138 (the old unlock was called inappropriate); #3466 / LUCENE-2391 (wrote today's try/finally); #1707, #2701, #3272, #1940, #5553, #9020, #11569, SOLR-2654, SOLR-1877, the 2015 solr-user 'write.lock' thread (two spellcheckers sharing a directory) and Stack Overflow 54238168 (an AnalyzingInfixSuggester directory): different defects. Context: AnalyzingInfixSuggester.build in the same module rolls back its writer on failure (since LUCENE-4845, 2013), and the Lucene.NET port disposes the writer on every exit. Gaps: keyword search only; GitHub search returns at most 50 results per query; solr-user covered by 3 queries only; private reports to security@lucene.apache.org are not visible.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle with assertions on and off and passes with the fix; release 10.6.0 jars: fails with assertions on and off. Earlier hunt runs on mainea57360912ed(the sameSpellChecker.java), 10.5.2, 9.12.3 and 29 releases from 1.9.1 to 10.5.2.
- Finding record
lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java :482-491 (javadoc '@throws IOException If there is a low-level I/O error', indexDictionary), :496 (new IndexWriter), :497 (obtainSearcher), :500 (reads the field 'searcher', not the local), :512 (try), :537-539 (finally releaseSearcher only), :541 (forceMerge), :544 (writer.close, success path only), :456 (clearIndex opens its own IndexWriter), :639-647 (close() closes only the searcher's reader)lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java :68 (static LOCK_HELD set), :108 (added on obtain), :146 (removed only when the lock is closed); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :258 (MAX_TERM_LENGTH), :1480-1481 (addDocument javadoc: longer terms throw IllegalArgumentException)lucene/suggest/src/java/org/apache/lucene/search/spell/PlainTextDictionary.java :49-50 (Path: Files.newBufferedReader UTF-8), :58-59 (InputStream: IOUtils.getDecodingReader, which reports malformed input) and :77-96 (FileIterator.next closes its reader on any Throwable, :92-94); lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java :362-366 (build rolls back its writer on any Throwable)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:445-460, 482-552, 612-648a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:68, 100-150a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/PlainTextDictionary.java:44-99a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:258, 1476-1481, 1629-1641, 5084-5113, 6085-6104; DocumentsWriterPerThread.java:585-603
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:482-491a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingInfixSuggester.java:362-368a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/spell/PlainTextDictionary.java:92-95GitHub apache/lucene #3550, #2229, #2215, #3466 (titles and labels, gh CLI, read-only, 2026-11-01)
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1307-1338, 1363-1369, 2566-2576; lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:497-512a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/IOUtils.java:97-137
- Prior reports
gh CLI searches, read-only, 2026-11-01
- History
a Lucene clone (refs fetched 2026-10-31): SpellChecker.java blobs at releases/lucene/10.6.0 and 10.5.2 (60202bcb10a), origin/main 2f424fd428d and 645d4fe14a20 (643b7469b7e); diff: class-javadoc markup only
- TestSpellCheckerFailedIndexDictionary.java:
repro/LFV-549/src/org/apache/lucene/search/spell/TestSpellCheckerFailedIndexDictionary.java(49 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-549/bug.png(513 KB)
