The constructor takes the write lock first, then does more work, and has no cleanup if that work fails.
- Lucene's monitor module matches documents against many stored queries. A persistent
Monitorkeeps those queries in a small Lucene index on disk, written by an internalIndexWriterthat it creates when it opens and closes inclose(). When it opens an existing index, it re-parses every stored query string with yourMonitorQuerySerializer. - An
IndexWriterholds the directory'swrite.lockuntil it is closed. WithFSDirectory's defaultNativeFSLockFactory, the JVM also records held locks in a static set, so a second writer on the same directory in the same JVM fails at once withLockObtainFailedException. - When a Java constructor throws, the caller receives no object. Only the constructor itself can release what it already acquired.
- Open a persistent Monitor (
setIndexPath), register 1 query (f:a) and close it. Reopen it with a parser that rejects the stored string, as after a parser change between two versions of your application. - That open throws
IllegalStateException: Couldn't parse some queries from the index, as it should. But its constructor had already built anIndexWriter, which holds the index'swrite.lock. - You switch back to the working parser and retry in the same JVM. You expect the index to open with its 1 query: a constructor that throws must release what it took, as
IndexWriter's own constructor does. - Instead the retry, and all 200 retries in a probe, fail with
LockObtainFailedException: Lock held by this virtual machine. The lock stays recorded as held in this JVM until it restarts, even after the unreachable writer is garbage-collected. - Nothing is lost: the index on disk is intact and a read-only Monitor on it still matches correctly. An I/O error while opening, a missing serializer or
setPurgeFrequency(0, ...)leak the lock the same way.
Users of the optional monitor module with a query index on disk (setIndexPath or setDirectoryProvider; the default Monitor is in memory) whose open fails and who retry in the same JVM, for example a service that reloads its configuration. Writable reopens of that index fail until a restart; nothing on disk is lost, and the leak is bounded per path.
Mechanism, terms and numbers697 words
new Monitor(analyzer, config) with a query index on disk (MonitorConfiguration.setIndexPath or setDirectoryProvider) builds a WritableQueryIndex. Its constructor first builds an IndexWriter (WritableQueryIndex.java:77), which takes the index's write.lock, and only then opens a SearcherManager (:79), re-parses every stored query (:83) and schedules the purge task (:85-98). Nothing after :77 is inside a try, so when any of those steps throws, the constructor throws with the writer still open. The caller gets no Monitor, so close(), the only release (:322-325), can never run, and NativeFSLockFactory keeps the lock in a static JVM-wide set until the lock is closed. With that default lock factory, every later writable Monitor on that path in the JVM then fails with LockObtainFailedException ("Lock held by this virtual machine"), even with a correct configuration. Four triggers were executed: stored queries the current parser rejects, a non-empty index opened without a serializer, an I/O error while the index opens, and setPurgeFrequency(0, ...). Nothing on disk is lost, and read-only Monitors and other paths keep working. IndexWriter's own constructor releases its lock on any failure; this one never did. Reproduced on nine releases from 8.2.0 to 10.6.0 and on main; garbage collection frees the writer but not the JVM's record of the lock. Low priority: it needs a failed open of an opt-in persistent Monitor and a retry in the same JVM.
// WritableQueryIndex(MonitorConfiguration, Presearcher), condensed
this.writer = configuration.buildIndexWriter(); // :77 new IndexWriter: takes write.lock
this.manager = new SearcherManager(writer, true, true, ...); // :79 opens a reader: I/O error -> leak
...
populateQueryCache(serializer, decomposer); // :83 IllegalStateException -> leak
long purgeFrequency = configuration.getPurgeFrequency();
this.purgeExecutor = Executors.newSingleThreadScheduledExecutor(...);
this.purgeExecutor.scheduleAtFixedRate(..., purgeFrequency, purgeFrequency, ...); // :88 period <= 0 -> leak
// no try/catch anywhere after :77MonitorConfiguration.buildIndexWriter()(MonitorConfiguration.java:93-95) returnsnew IndexWriter(directoryProvider.get(), ...);setIndexPathopens a freshFSDirectoryfor each Monitor (:87-91).IndexWriterobtainswrite.lock(IndexWriter.java:954). IfIndexWriter's own constructor fails, it releases the lock itself (IndexWriter.java:1202-1209, "init: hit exception on init; releasing write lock").- Every exception after
:77escapes with the writer open. Four triggers, each executed:- I/O error while the
SearcherManageropens the index (:79), with a correct serializer and parseable queries (aFilterDirectorythat throws on the doc-values file). setPurgeFrequency(0, ...):MonitorConfiguration.java:130-134stores any value without validation, andscheduleAtFixedRatethrowsIllegalArgumentExceptionfor a period <= 0, even on an empty index.- Stored queries the serializer rejects:
populateQueryCachecollects every parse error and throwsIllegalStateException("Couldn't parse some queries from the index")(:173-180). This is the minimal repro. - A non-empty index with no serializer:
IllegalStateException("Attempting to open a non-empty monitor query index with no MonitorQuerySerializer")(:139-150; non-empty means at least one live query document).
- I/O error while the
- The exception leaves
Monitor's constructor too (Monitor.java:87-99). The only releases,WritableQueryIndex.close()(:322-325: shut the executor down, then close the manager, the writer and the directory) andMonitor.close()(Monitor.java:142-145), belong to objects the caller never received. NativeFSLockFactoryadds the lock path to its staticLOCK_HELDset when it obtains the lock (NativeFSLockFactory.java:68,:108) and removes it only when that lock is closed (:145-150); a later attempt throws "Lock held by this virtual machine" (:119). Garbage collection does not help: it frees the unreachable writer and closes its lock file channel, but the path stays inLOCK_HELD, so the retry still fails (an independent verifier's test, fiveSystem.gc()rounds; a later probe, ten rounds, with a weak reference showing the writer was collected). One undocumented workaround: subclassMonitorConfiguration, override the publicbuildIndexWriter()to keep the writer, and close it yourself. Another, also undocumented and executed: asetDirectoryProviderwhoseFilterDirectoryrecords theLockfromobtainLock; closing thatLockafter the failed open lets the retry open with its 1 query, but it releases only the lock, not the writer or its reader.
By reading only, not claimed: the default in-memory Monitor (MonitorConfiguration.java:41, a new ByteBuffersDirectory per open) shows no lock error, because every open gets its own directory.
Exposure and likelihood280 words
- Who. Applications of the optional
lucene-monitormodule that keep the query index on disk. This is opt-in: "By default, Monitor instances are ephemeral, storing their query indexes in memory" (package-info.java:99-100). - When. An open fails (most realistically an I/O error, or stored queries that a new version of the application's parser no longer accepts) and the application then retries or reopens in the same JVM instead of exiting: a long-running service that reloads its configuration, falls back to a lenient parser, or opens the index writable to delete the bad query; or a test suite. A startup failure followed by a process restart never sees it. Likelihood is rated rare: no report since 8.2.0 (2019).
- What goes wrong (executed probe on main with assertions on and off, 10.5.2 and 9.12.3): every writable open of that path fails until the JVM restarts; 200 retries all failed and added 0 file descriptors. The message says the lock is held, not why, so it looks like a second writer the application does not have. Each failed open leaves an unreachable
IndexWriterwith its lock file channel and, except for the I/O-error trigger (wherenew SearcherManageritself throws), aSearcherManagerwith its reader. Garbage collection frees the writer and closes the channel, but the path stays recorded as locked in the JVM, and the reader's index-file mapping stays (seen for the parser trigger), until a restart. - What it does not do (same probe): nothing on disk is lost or corrupted (a plain
DirectoryReadersees all 3 stored queries); a read-only Monitor on the same path loads all 3 and matches exactly the right ones; Monitors on other paths and in-memory Monitors keep working.
One JUnit test on the public API: it stores 1 query in a persistent Monitor, reopens it with a parser that rejects the stored string (the expected IllegalStateException), then retries with the working parser in the same JVM.
/*
* 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.monitor;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.LockObtainFailedException;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMonitorReopenAfterFailedOpen extends LuceneTestCase {
private static Monitor open(Path path, MonitorQuerySerializer serializer) throws IOException {
return new Monitor(
new WhitespaceAnalyzer(), new MonitorConfiguration().setIndexPath(path, serializer));
}
public void testReopenAfterRejectedStoredQuery() throws IOException {
Path path = createTempDir();
MonitorQuerySerializer serializer =
MonitorQuerySerializer.fromParser(s -> new TermQuery(new Term("f", s)));
try (Monitor monitor = open(path, serializer)) {
monitor.register(new MonitorQuery("q", new TermQuery(new Term("f", "a")), "a", Map.of()));
}
MonitorQuerySerializer rejecting =
MonitorQuerySerializer.fromParser(
s -> {
throw new IllegalArgumentException("cannot parse " + s);
});
expectThrows(IllegalStateException.class, () -> open(path, rejecting));
try (Monitor monitor = open(path, serializer)) {
assertEquals(1, monitor.getQueryCount());
} catch (LockObtainFailedException e) {
throw new AssertionError(
"expected the retry with the working serializer to open the index, but got " + e, e);
}
}
}
cp $REPRO/TestMonitorReopenAfterFailedOpen.java $LUCENE/lucene/monitor/src/test/org/apache/lucene/monitor/
cd $LUCENE && ./gradlew :lucene:monitor:test --tests org.apache.lucene.monitor.TestMonitorReopenAfterFailedOpen -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
- Expected
- After the rejected open, a retry with the working serializer opens the index and sees its 1 query.
- Observed
- The retry throws
LockObtainFailedException: Lock held by this virtual machine: .../write.lock(assertions on and off).
More files, environment, full output304 words
One test class, one method. It starts no threads of its own (each Monitor it opens successfully starts its purge thread, as every Monitor does), and the lock failure reproduced with every seed and assertion mode tried. It sits in org.apache.lucene.monitor next to the module's own tests but uses only public API (Monitor, MonitorConfiguration.setIndexPath, MonitorQuerySerializer.fromParser, MonitorQuery, TermQuery, WhitespaceAnalyzer; the test framework's createTempDir and expectThrows). The rejecting parser models a parser change between application versions. The retry is wrapped only to turn the LockObtainFailedException into a message stating expected vs actual.
The suite-level "file handle leaks" failure is the test framework seeing the same leaked writer's lock channel, not a second bug; it does not appear on 10.5.2 or with every seed, while the lock failure itself reproduced with every seed and version tried.
This same test is the suggested regression test: it fails on stock main and passes with the fix. It passes the module's format, ECJ and forbidden-apis checks unchanged.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (module prefixes, environment lines and framework frames cut)
TestMonitorReopenAfterFailedOpen > testReopenAfterRejectedStoredQuery FAILED
java.lang.AssertionError: expected the retry with the working serializer to open the index, but got org.apache.lucene.store.LockObtainFailedException: Lock held by this virtual machine: $LUCENE/lucene/monitor/build/tests-tmp/lucene.monitor.TestMonitorReopenAfterFailedOpen_001/tempDir-001/write.lock
at __randomizedtesting.SeedInfo.seed([DEADBEEF:5894959657BDB0E2]:0)
at org.apache.lucene.monitor.TestMonitorReopenAfterFailedOpen.testReopenAfterRejectedStoredQuery(TestMonitorReopenAfterFailedOpen.java:51)
Caused by:
org.apache.lucene.store.LockObtainFailedException: Lock held by this virtual machine: $LUCENE/.../tempDir-001/write.lock
at org.apache.lucene.store.NativeFSLockFactory.obtainFSLock(NativeFSLockFactory.java:119)
at org.apache.lucene.store.FSLockFactory.obtainLock(FSLockFactory.java:43)
at org.apache.lucene.store.BaseDirectory.obtainLock(BaseDirectory.java:44)
at org.apache.lucene.index.IndexWriter.<init>(IndexWriter.java:954)
at org.apache.lucene.monitor.MonitorConfiguration.buildIndexWriter(MonitorConfiguration.java:94)
at org.apache.lucene.monitor.WritableQueryIndex.<init>(WritableQueryIndex.java:77)
at org.apache.lucene.monitor.Monitor.<init>(Monitor.java:95)
at org.apache.lucene.monitor.Monitor.<init>(Monitor.java:77)
at org.apache.lucene.monitor.TestMonitorReopenAfterFailedOpen.open(TestMonitorReopenAfterFailedOpen.java:32)
at org.apache.lucene.monitor.TestMonitorReopenAfterFailedOpen.testReopenAfterRejectedStoredQuery(TestMonitorReopenAfterFailedOpen.java:48)
TestMonitorReopenAfterFailedOpen > executionError FAILED
java.lang.RuntimeException: file handle leaks: [FileChannel($LUCENE/.../tempDir-001/write.lock)]
at org.apache.lucene.tests.mockfile.LeakFS.onClose(LeakFS.java:63)
...
2 tests completed, 2 failed
# the same two failures with -Ptests.asserts=false; the expectThrows on line 47 passes (the rejected open fails first)IndexWriter's own constructor releases its write lock on any failure; upstream fixed that same symptom there in 2010 (LUCENE-2476).- The package docs describe reloading a persistent index "by a new Monitor instance"; the failing retry does exactly that.
- Two triggers break no precondition: an I/O error while opening, and
setPurgeFrequency(0, ...), which is accepted unchecked.
The full argument193 words
- The codebase rule.
IndexWriter's constructor catches anyThrowable, including configuration it rejects, and releases its lock (IndexWriter.java:1202-1209). It was added as a bug fix: LUCENE-2476, "release write lock on any exception during IndexWriter ctor" (2010). - Ownership.
WritableQueryIndex.close()releases exactly what the constructor acquires (executor, searcher manager, writer, directory;WritableQueryIndex.java:322-325). A caller of a failed constructor has no object to close and no unlock API. - The documented use is reopening.
package-info.java:104-107: stored queries must "be re-parsed by the associated MonitorQuerySerializer when the index is loaded by a new Monitor instance". - Two triggers break no precondition. An I/O error is an ordinary failure the constructor declares;
setPurgeFrequencydocuments only "the frequency value" and accepts 0 (MonitorConfiguration.java:123-134). - Nothing says otherwise. No javadoc, test,
CHANGES.txtorMIGRATE.mdentry says a failed open keeps the lock; the upstream monitor tests never retry after a failed open, and their purge tests use only positive frequencies. The twoIllegalStateExceptionmessages are code-level validation errors, in no javadoc and no upstream test. The constructor shape came with the module (LUCENE-8766, 2019) and survived the 2022 refactor (LUCENE-10422) without any discussion of failure cleanup.
The devil's-advocate check191 words
The strongest "works as intended" case (built by a devil's-advocate verifier and by cross-model reviews, OpenAI GPT-5.6): the package docs make re-parseability of stored queries the application's job, so the parser and null-serializer triggers start from an application error; scheduleAtFixedRate documents IllegalArgumentException for a non-positive period, so 0 is invalid configuration; no javadoc promises that a failed Monitor construction can be retried; Closeable covers only constructed objects; the lock exists to stop two writers; and a restart clears it.
Why it fails: those points justify the first exception, not keeping a native lock on a writer nobody can reach. IndexWriter releases its lock even for configuration it rejects; the I/O-error trigger breaks no precondition at all (it is the strongest case; the parser and null-serializer triggers do start from an application error); the "second writer" is unreachable garbage, and even once garbage collection frees it the JVM still records the lock as held; and a restart is a workaround. The devil's-advocate verifier rated "not intended" high; the earlier cross-model reviews rated the intended argument moderate; a later cross-model review that ran its own repro rated the bug real with high confidence.
Recommended A. try/catch (Throwable) after buildIndexWriter(): shut the executor down, close the manager, roll back the writer, close the directory, rethrow
Wrap everything after buildIndexWriter() in WritableQueryIndex's constructor in try/catch (Throwable): shut the purge executor down if created, close the searcher manager, roll back the writer, close the directory, rethrow (+14/-3 ignoring whitespace). The repro, the monitor module's 182 tests, lint and all four triggers pass.
this.writer = configuration.buildIndexWriter();
ScheduledExecutorService executor = null;
try {
// ... SearcherManager, populateQueryCache: unchanged ...
executor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("cache-purge"));
executor.scheduleAtFixedRate(/* unchanged arguments */);
this.purgeExecutor = executor;
} catch (Throwable t) {
if (executor != null) {
executor.shutdown();
}
IOUtils.closeWhileSuppressingExceptions(t, manager, writer::rollback, writer.getDirectory());
throw t;
}All options and trade-offs3 options · 594 words
Option A was run through gradle in a scratch worktree of 645d4fe14a20, and against the other three triggers' tests. B complements it; C is the narrower first fix and shows why the guarded region must reach the end of the constructor. Building the writer later is not a fix: populateQueryCache reads the stored queries through the searcher manager built on the writer.
A. try/catch (Throwable) after buildIndexWriter(): shut the executor down, close the manager, roll back the writer, close the directory, rethrow (validated by the checks) (recommended)
In WritableQueryIndex.java:77-98: the rest of the constructor moves, unchanged and re-indented, into the try; the executor goes through a local because a blank final field cannot be read in the catch. Ignoring whitespace the change is +14/-3.
this.writer = configuration.buildIndexWriter();
ScheduledExecutorService executor = null;
try {
// ... SearcherManager, populateQueryCache: unchanged ...
executor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("cache-purge"));
executor.scheduleAtFixedRate(/* unchanged arguments */);
this.purgeExecutor = executor;
} catch (Throwable t) {
if (executor != null) {
executor.shutdown();
}
IOUtils.closeWhileSuppressingExceptions(t, manager, writer::rollback, writer.getDirectory());
throw t;
}Trade-offs
- Covers all four triggers, because the guarded region includes the executor creation and scheduling.
- Roll back, not close:
IndexWriter.close()commits by default (IndexWriter.java:1363-1369);rollback()commits nothing, so a failed open leaves the index as it was (a failed first open of an empty directory creates no commit). - Same resources and order as
close()(WritableQueryIndex.java:322-325). Likeclose(), it closes the directory the provider returned; for asetDirectoryProviderthat hands out a sharedDirectorythat is the existing ownership rule, but a caller who retries with that same instance would then find it closed (the provider API does not say who owns the directory when the open fails). - The original exception is rethrown, with any cleanup failure suppressed into it, the same idiom as
IndexWriter.java:1202-1209; only if a cleanup step throws anError(and the original is not one) does the helper throw thatErrorinstead, with the original suppressed (IOUtils.java:116-136). The helper exists from 10.4.0 on; a 9.x backport would useIOUtils.closeWhileHandlingException(not run).
What the checks validated
- Stock
645d4fe14a20: the repro fails with assertions on and off. - With the fix: the repro plus
TestMonitorPersistence,TestMonitorReadonlyandTestCachePurgingpass with assertions on and off (4 suites, 15 tests, 0 failures); the whole:lucene:monitor:testpasses with assertions on (33 suites, 182 tests, 0 failures, 1 skipped);checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass. - The other triggers, against the built main jars with the fixed class shadowed: the purge-frequency-0 check (stock fails, fixed passes), the I/O-error and garbage-collection tests (stock 2 fail, control passes; fixed 3 of 3), the null-serializer and rejected-query tests (stock 2 fail; fixed 2 of 2); no leaked-file report with the fix.
Reject frequency <= 0 in MonitorConfiguration.setPurgeFrequency (or at the start of the constructor) with a clear message.
Trade-offs
- Covers only the purge-frequency trigger and gives a better error; the other three still need A. Open issue #11710 asks for a way to disable the scheduled purge, so maintainers may prefer 0 to mean "disabled".
try/finally around SearcherManager creation and populateQueryCache, with IOUtils.closeWhileHandlingException(manager, writer, writer.getDirectory()) on failure.
Trade-offs
- Passes the minimal repro, but stops before the executor, so
setPurgeFrequency(0, ...)still leaks the lock with it (executed twice, by reviewers); it also closes the writer, which commits by default (commitOnClose), instead of rolling it back.
A read-only code search found no use of the monitor module in Elasticsearch, OpenSearch or Solr (an open Solr PR proposes adding it), so the exposure is mainly applications that embed lucene-monitor directly.
Found by reading the serializer error path while building model-based tests of the Monitor's persistence lifecycle; those tests never retried after a failed open, so they did not hit it themselves. A JUnit test on the stock jars then confirmed the lock leak.
The method in full109 words
Method: code reading (non-formal), in a hunt unit on the monitor module's lifecycle and persistence whose secondary focus was serializer edge cases, such as a serializer that throws on one query.
- While modelling the persistence lifecycle for operation-level model-based tests, the finder read the constructor's serializer error path and saw that nothing closes the writer built before it.
- A public-API JUnit test (null serializer; a parser that rejects the stored string, then a retry) failed 2 of 2 on main with assertions on and off, 10.5.2 and 9.12.3.
- Independent verifiers added the I/O-error trigger and the purge-frequency trigger; the latter showed the first fix draft was too narrow.
No prior report in two rounds of searches (2026-10-26 and 2026-11-01, more than 340 queries plus full listings) over GitHub issues and PRs, the JIRA archive, the dev@, java-user@ and issues@ lists, the predecessor project, downstream projects and the web. The nearest item, #11710, asks to disable the scheduled purge.
Searches4 searches
Verdict new. Closest items, none the same bug: #11710 (open; disabling the scheduled purge, relevant only to the purge-frequency trigger), #14886 / #14885 (a different reload bug, fixed; one real way stored queries fail to reload), #679 / #11458 (created WritableQueryIndex; the review covered close(), not a failing constructor), #10908 / #99 (configurable purge executor), luwak#174 (intended single-writer lock errors) and #7566 (background on the in-JVM held-lock record). No open PR adds cleanup to the constructor. Gaps: keyword search only; private reports to security@lucene.apache.org are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, the Luwak predecessor project, downstream projects, all of GitHub) | 2026-10-26; refreshed 2026-11-01 | scripted prior-report search runs (102 queries on 2026-10-26; 51 plus 30 on 2026-11-01) and 37 + 59 manual searches: class and method names, populateQueryCache, buildIndexWriter, setIndexPath, setDirectoryProvider, setPurgeFrequency, the three error messages as exact phrases, leak, lock and reopen phrasings; full listings of the 50 module:monitor issues and PRs and the 191 Luwak issues | #11710, #14886 / #14885, #679 / #11458, #10908 / #99, luwak#174, #7566; none reports this bug |
| Open PRs touching the monitor files | 2026-10-26; refreshed 2026-11-01 | the open module:monitor PRs and their diffs | #16847 (only numDocs()), #13948 (only the deserialize input), #99 (replaces the purge scheduler), #14660, #13995, #13993; none adds failure cleanup |
| Mailing lists and JIRA | 2026-10-26; refreshed 2026-11-01 | dev@, java-user@, issues@ (manual and scripted queries); JQL over the whole modules/monitor component; LUCENE-8766 and its 28 comments read | no report |
| Web and git history | 2026-10-26; refreshed 2026-11-01 | 15 web searches; git log -S/-G over lucene/monitor in the full history clone | no report; no commit ever added cleanup to the constructor |
Latent since the module was added (LUCENE-8766, 2019; first release 8.2.0). The 2022 refactor (LUCENE-10422) moved the constructor and added the purge executor without cleanup. Reproduced on nine releases from 8.2.0 to 10.6.0 and on main (all with assertions on; 10.6.0 also off).
Versions tested7 rows
Introduced by 251dbe7cea8 "LUCENE-8766: Add monitor subproject" (2019-06-13) and its branch_8x twin b8773d13ad8, which shipped in 8.2.0; the review PR, apache/lucene-solr#648, was closed because the code was committed directly. Its QueryIndex constructor built the writer and the searcher manager, then re-parsed the stored queries, with no try. Moved, not fixed by LUCENE-10422 "Read-only monitor implementation" (#679, 2022-03-21; 72cf36a2b8d on branch_9x, released in 9.2.0; be991789564 on main, released in 10.0.0), which created WritableQueryIndex and added the purge-executor setup after the re-parse.
All 49 release tags with the module in the history clone (8.2.0-8.8.1, 9.0.0-10.6.0) have no cleanup between buildIndexWriter() and the re-parse; the 8.9-8.11 tags are missing there, so 8.11.4 was executed. 8.x and 9.0.0 ran a port of the test that only changes the LuceneTestCase import.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.1.1 | not affected | no monitor module; the API does not exist |
| 8.2.0 | affected | the retry fails with LockObtainFailedException; with a cleanup overlay on the constructor the test passes |
| 8.11.4, 9.0.0, 9.1.0 | affected | the retry fails (release jars) |
| 9.2.0, 9.12.3, 10.0.0, 10.5.2 | affected | the retry fails; 9.2.0 is the first release with WritableQueryIndex |
| 10.6.0 | affected | the retry fails, assertions on and off |
main 645d4fe14a20 | affected | the minimal test fails through gradle with assertions on and off; passes with the fix |
upstream main 2f424fd428da | by reading | no change under lucene/monitor since 645d4fe14a20 |
Confirmed with high confidence: the finder, an independent reproducer, a devil's-advocate verifier and an adjudication each reproduced it; a cross-model review (OpenAI GPT-5.6) that ran its own repro rated it real, high; this doc re-ran it through gradle and validated a fix that covers all four triggers.
Checks and verdicts375 words
Everyone agrees on the root cause (any exception after buildIndexWriter() leaks the writer) and that the harm is a write lock-out until restart, not data loss; the verifiers lowered the finder's first impact call ("index unusable") to crash.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Code reading of the serializer error path; its JUnit (null serializer; rejected stored query) fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3, and passes with its fix. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real (procedural) | It could not run code, so its verdict is procedural; it agreed with the root cause and rated the intended argument moderate; a separate re-run of the repro failed. |
| independent reproducer | real | Own test written from the claim: 3 retry tests fail on main (assertions on and off), 10.5.2 and 9.12.3; the control (clean close, then reopen) passes; a framework-free main() fails on main and 8.11.4. |
| cross-model review, intended lens (OpenAI GPT-5.6, read-only) | real, medium | Intended argument moderate; separate probes found the purge-frequency trigger and showed the first fix draft misses it. |
| devil's-advocate verifier | real (high that it is not intended) | An injected I/O error with a correct serializer leaks the lock; garbage collection does not release it; closing the writer captured through buildIndexWriter() does. |
| adjudication | confirmed, high | Re-ran the reproducer's test on main with assertions on and off: the 3 retry tests fail, the control passes; with the fix 4 of 4 pass. |
| history, security screen, audit, minimization | 8.2.0 first affected; security none; confirmed; verified | Nine releases plus main run; a security probe on main, 10.5.2 and 9.12.3 (bounded leak, data intact, read-only Monitor exact); the one-test repro on 645d4fe14a20 (two seeds with assertions on, one off) and on 10.5.2. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed, 2026-11-01) | real, high | Its own framework-free main() with setPurgeFrequency(0, DAYS) fails on main with assertions on and off; the first fix draft still fails it. |
| this doc (2026-11-01) | reproduced, fix validated | Gradle on 645d4fe14a20: the minimal test fails with assertions on and off; with the fix it and the monitor module's 182 tests pass, lint passes, and the tests for all four triggers pass. |
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 470 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,553 words
- Registry title
- When opening a persistent Monitor fails after its query index has built the IndexWriter (stored queries the serializer cannot parse, no serializer for a non-empty index, an I/O error, or a purge frequency of 0), the writer and its write lock are never released, so the same index cannot be opened for writing again in that JVM
- Area
- monitor / persistent query index (WritableQueryIndex construction, failure cleanup)
- Priority
- Low, rank 1082: A failed persistent Monitor open leaks the write lock so same-JVM retries fail until restart, but it needs a failed open of an opt-in persistent Monitor.
- Impact class
- crash
- Impact note
- Applications of the optional lucene/monitor module that keep their query index on disk (setIndexPath or setDirectoryProvider, opt-in: the default Monitor is in memory) and retry or reopen in the same JVM after a failed open (stored query strings the current serializer rejects, for example after a parser change between application versions; a null serializer on a non-empty index; an I/O error while the query index opens; or setPurgeFrequency(0, ...)) get LockObtainFailedException 'Lock held by this virtual machine' on every writable open of that path until the JVM restarts, with one IndexWriter, one SearcherManager with its reader and one write.lock file channel leaked per failed path; the index on disk is intact, a read-only Monitor on the same path and Monitors on other paths keep working, 200 further retries leaked nothing more [exec], and no scale is needed.
- Summary
- You open a persistent Monitor (MonitorConfiguration.setIndexPath) and the open fails after the query index has built its IndexWriter, for example because a stored query no longer parses with the current parser (IllegalStateException 'Couldn't parse some queries from the index'), because no serializer was given for a non-empty index, or because of an I/O error while the index opens. You expect the failed constructor to release what it acquired, as IndexWriter's own constructor does for its write lock, so that you can fix the parser or the configuration and open the index again. Instead the IndexWriter, its SearcherManager and the write lock stay open with no object left to close them, so every later writable Monitor on that path in the same JVM fails with LockObtainFailedException 'Lock held by this virtual machine', even with a correct serializer, until the JVM restarts.
- Introduced
- 251dbe7cea8126cb7ecadc921ba444c63d06c618 (main, 2019-06-13, 'LUCENE-8766: Add monitor subproject', by GitHub romseygeek), with the branch_8x twin b8773d13ad8e249a045862249bf476c30a444461 (same message, author and date); review PR apache/lucene-solr#648, closed on GitHub because the code was committed directly. Its QueryIndex constructor (QueryIndex.java:90-97) built the writer and the SearcherManager and then called populateQueryCache, which held both IllegalStateExceptions, with no try/finally. LUCENE-10422 / #679 (be991789564 on main, first released in 10.0.0; 72cf36a2b8d on branch_9x, first released in 9.2.0) moved the constructor into WritableQueryIndex and added the purge-executor setup after populateQueryCache, keeping the missing cleanup (git blame on main points at be991789564 for the writer and SearcherManager lines only because that refactor rewrote them). [read: git log -S/-G over lucene/monitor finds no try/finally, success flag or closeWhileHandlingException ever added to the constructor; constructor body printed at every 8.x, 9.x and 10.x release tag in the history clone; exec at release granularity, see affected_versions]. Latent since the API was added, not a regression. (2019-06-13 (251dbe7cea8 on main and b8773d13ad8 on branch_8x))
- First affected version
- 8.2.0 [exec] (the first lucene-monitor release); there is no unaffected earlier release: 8.1.1 has no lucene/monitor module and lucene-monitor 8.1.1 is not on Maven Central (HTTP 404)
- Affected versions
- Every release that ships lucene-monitor, 8.2.0 to 10.6.0, plus main ea57360912ed. [exec] a framework-free public-API probe (a control plus a parse-failure and a null-serializer scenario, System.gc() before each retry) is affected, with the control passing, on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da), and passes on main with the finder's fix shadowed; the finder's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3, and its main() variant on main and 8.11.4; the intended-behaviour check's JUnit, including the I/O-error trigger, fails on main (-ea and -da) and 10.5.2; the one-test minimal repro fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da (2026-11-01). [read] the constructor body has the same shape at every release tag 8.2.0-8.8.1 and 9.0.0-10.5.2 in the history clone (the 8.9-8.11 tags are absent there; 8.11.4 was executed); upstream main and branch_10x are unchanged on 2026-10-26. Not executed: x86; the I/O-error and purge-frequency triggers on older releases (from 8.2.0 to 9.1.0 the purge executor was created in Monitor.<init> after the query index was built, so that path also leaks the writer there, by code reading).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code and voted “not real” while agreeing with the root cause (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
- non-formal: code reading
- Upstream and prior reports
- none found (searched 2026-10-26, read-only): two scripted prior-report searches (102 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web), 37 manual GitHub issue and PR searches (apache/lucene with the class and method names and all three error messages as exact phrases; flaxsearch/luwak, apache/solr and other downstream repositories; global searches for WritableQueryIndex and the messages), 6 open-PR checks, about 20 issues and PRs read with their comments, 15 manual list queries, 6 JQL queries (the whole modules/monitor component; the LUCENE-8766 description and its 28 comments read) and 7 web searches. Not fixed upstream: the constructor is unchanged on upstream main and branch_10x, no open PR touches its failure cleanup (#16847 changes only numDocs(), #13948 only the deserialize input inside populateQueryCache, #99 is stale since 2021), and no commit in lucene/monitor ever added closeWhileHandlingException. Nearest items, not reports of this bug: #11710 (open; asks for a way to disable the scheduled purge, relevant only to the purge-frequency trigger), #14886 / #14885 (a different reload bug, fixed; one realistic way stored queries fail to load), #679 / #11458 (created WritableQueryIndex; the review covered the order of steps in close(), not a failing constructor), #10908 / #99 (configurable purge executor; no failure cleanup), luwak#174 (predecessor project; intended single-writer lock errors) and #7566 (background on the in-JVM held-lock record). Gaps: keyword search only; a report that names none of the classes, methods or messages could be missed; private reports to security@lucene.apache.org are not visible.
- Lucene commit tested
- main
645d4fe14a20(2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off (LockObtainFailedExceptionon the retry, plus the test framework's report of the leakedwrite.lockchannel). With the recommended fix it passes, together withTestMonitorPersistence,TestMonitorReadonlyandTestCachePurging(assertions on and off), the whole monitor module (33 suites, 182 tests, 0 failures, 1 skipped) and the module's format, ECJ and forbidden-apis checks; the other three triggers (the verifiers' and a cross-model reviewer's tests, run against the built main jars with the fixed class shadowed) fail on stock code and pass with the fix. Upstream main2f424fd428dahas no change underlucene/monitorsince645d4fe14a20(read). Earlier runs: the minimal test on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (all affected), and the hunt's and verifiers' tests on mainea57360912ed, 10.5.2 and 9.12.3.
- Finding record
lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java :74-99 (constructor: buildIndexWriter :77, SearcherManager :79, populateQueryCache :83, purge executor and scheduleAtFixedRate :85-98, no try/finally), :137-181 (populateQueryCache; IllegalStateExceptions :144-145 and :173-179), :322-324 (close() is the only release)lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java :41 (default directoryProvider = new ByteBuffersDirectory), :87-91 (setIndexPath opens an FSDirectory per Monitor), :93-95 (buildIndexWriter), :130-134 (setPurgeFrequency, not validated); Monitor.java :92-96 (read-only vs writable query index); package-info.java :97-107 ('By default ... ephemeral'; stored queries must be re-parseable by the serializer)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :954 (obtainLock), :1202-1209 (IndexWriter's own constructor releases the write lock on any Throwable); lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java :68 (static LOCK_HELD), :108-119 ('Lock held by this virtual machine'), :145-150 (cleared only when the lock is closed)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:74-99 (constructor), :139-150, :152-180 (populateQueryCache error paths), :322-325 (close)a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:41, :87-91, :93-95, :123-134a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:70-78, :87-99, :142-145a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:954, :1202-1209a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/NativeFSLockFactory.java:68, :108-120, :145-150, :212
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:97-107
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1202-1209a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:322-325; lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:123-134; lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:104-107
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1202-1209, :1363-1369, :2576-2583; lucene/core/src/java/org/apache/lucene/util/IOUtils.java:104-137a Lucene clone: IOUtils.closeWhileSuppressingExceptions at releases/lucene/10.4.0, 10.5.2, 10.6.0; absent at 10.3.2, 9.12.3 (git grep)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/IOUtils.java:116-136 (a cleanup Error is thrown instead, with the original suppressed)
- TestMonitorReopenAfterFailedOpen.java:
repro/LFV-507/src/org/apache/lucene/monitor/TestMonitorReopenAfterFailedOpen.java(55 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-507/bug.png(470 KB)
