All findings · Lucene finding · LFV-507 · monitor-failed-open-leaks-write-lock

A failed open of a persistent Monitor leaves its query index write-locked until the JVM restarts

If opening a persistent Monitor fails after it built its internal IndexWriter (say, a stored query no longer parses), the writer is never closed. Every later writable open of that index in the same JVM then fails with LockObtainFailedException, in every release checked since 8.2.0.

Summary

Priority
Low, rank 1082A 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
crash
Since
8.2.0 (2019, the module's first release)introduced 2019-06-13 · about 7 years
Affected
every release checked from 8.2.0 to 10.6.0, and main
Status
documented
Found by
code reading while building model-based tests of the Monitor's persistence lifecycle, then a JUnit repro

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

The finding in 30 seconds

The constructor takes the write lock first, then does more work, and has no cleanup if that work fails.

Background: the Monitor's query index and its lock

  1. Lucene's monitor module matches documents against many stored queries. A persistent Monitor keeps those queries in a small Lucene index on disk, written by an internal IndexWriter that it creates when it opens and closes in close(). When it opens an existing index, it re-parses every stored query string with your MonitorQuerySerializer.
  2. An IndexWriter holds the directory's write.lock until it is closed. With FSDirectory's default NativeFSLockFactory, 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 with LockObtainFailedException.
  3. When a Java constructor throws, the caller receives no object. Only the constructor itself can release what it already acquired.
Diagram of the bug: new Monitor(...) with a persistent query index builds an IndexWriter, which takes write.lock and records it in the JVM's held-lock set. The constructor then re-parses the stored query; the parser rejects it and the constructor throws IllegalStateException without closing the writer. The caller has no object to close. A retry with the working parser fails with LockObtainFailedException because the JVM still records the lock as held, even after the unreachable writer is garbage-collected. With the fix, the constructor releases the writer before rethrowing, and the retry opens the index with its 1 query.
The constructor builds the writer and takes the lock, a later step throws, the caller gets no object to close, and the JVM-wide lock record blocks every retry. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. 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.
  2. That open throws IllegalStateException: Couldn't parse some queries from the index, as it should. But its constructor had already built an IndexWriter, which holds the index's write.lock.
  3. 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.
  4. 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.
  5. 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.

Who is affected

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.

Mechanism (Lucene main 645d4fe14a20)

// 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 :77
  1. MonitorConfiguration.buildIndexWriter() (MonitorConfiguration.java:93-95) returns new IndexWriter(directoryProvider.get(), ...); setIndexPath opens a fresh FSDirectory for each Monitor (:87-91). IndexWriter obtains write.lock (IndexWriter.java:954). If IndexWriter's own constructor fails, it releases the lock itself (IndexWriter.java:1202-1209, "init: hit exception on init; releasing write lock").
  2. Every exception after :77 escapes with the writer open. Four triggers, each executed:
    • I/O error while the SearcherManager opens the index (:79), with a correct serializer and parseable queries (a FilterDirectory that throws on the doc-values file).
    • setPurgeFrequency(0, ...): MonitorConfiguration.java:130-134 stores any value without validation, and scheduleAtFixedRate throws IllegalArgumentException for a period <= 0, even on an empty index.
    • Stored queries the serializer rejects: populateQueryCache collects every parse error and throws IllegalStateException("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).
  3. 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) and Monitor.close() (Monitor.java:142-145), belong to objects the caller never received.
  4. NativeFSLockFactory adds the lock path to its static LOCK_HELD set 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 in LOCK_HELD, so the retry still fails (an independent verifier's test, five System.gc() rounds; a later probe, ten rounds, with a weak reference showing the writer was collected). One undocumented workaround: subclass MonitorConfiguration, override the public buildIndexWriter() to keep the writer, and close it yourself. Another, also undocumented and executed: a setDirectoryProvider whose FilterDirectory records the Lock from obtainLock; closing that Lock after 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-monitor module 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 IndexWriter with its lock file channel and, except for the I/O-error trigger (where new SearcherManager itself throws), a SearcherManager with 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 DirectoryReader sees 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.

Minimal repro

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.

TestMonitorReopenAfterFailedOpen.java55 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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);
    }
  }
}
Command
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).

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with the fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# 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)

Why it looks unintended

  • 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 any Throwable, 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; setPurgeFrequency documents only "the frequency value" and accepts 0 (MonitorConfiguration.java:123-134).
  • Nothing says otherwise. No javadoc, test, CHANGES.txt or MIGRATE.md entry 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 two IllegalStateException messages 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.

Suggested fix

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). Like close(), it closes the directory the provider returned; for a setDirectoryProvider that hands out a shared Directory that 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 an Error (and the original is not one) does the helper throw that Error instead, with the original suppressed (IOUtils.java:116-136). The helper exists from 10.4.0 on; a 9.x backport would use IOUtils.closeWhileHandlingException (not run).

What the checks validated

  • Stock 645d4fe14a20: the repro fails with assertions on and off.
  • With the fix: the repro plus TestMonitorPersistence, TestMonitorReadonly and TestCachePurging pass with assertions on and off (4 suites, 15 tests, 0 failures); the whole :lucene:monitor:test passes with assertions on (33 suites, 182 tests, 0 failures, 1 skipped); checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass.
  • 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.

B. Validate the purge frequency before acquiring anything (complement, not run)

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".

C. Guard only the searcher manager and populateQueryCache (the first fix draft; not recommended)

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, the Luwak predecessor project, downstream projects, all of GitHub)2026-10-26; refreshed 2026-11-01scripted 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 files2026-10-26; refreshed 2026-11-01the 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 JIRA2026-10-26; refreshed 2026-11-01dev@, java-user@, issues@ (manual and scripted queries); JQL over the whole modules/monitor component; LUCENE-8766 and its 28 comments readno report
Web and git history2026-10-26; refreshed 2026-11-0115 web searches; git log -S/-G over lucene/monitor in the full history cloneno report; no commit ever added cleanup to the constructor

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
8.1.1 not affectedno monitor module; the API does not exist
8.2.0 affectedthe retry fails with LockObtainFailedException; with a cleanup overlay on the constructor the test passes
8.11.4, 9.0.0, 9.1.0 affectedthe retry fails (release jars)
9.2.0, 9.12.3, 10.0.0, 10.5.2 affectedthe retry fails; 9.2.0 is the first release with WritableQueryIndex
10.6.0 affectedthe retry fails, assertions on and off
main 645d4fe14a20 affectedthe minimal test fails through gradle with assertions on and off; passes with the fix
upstream main 2f424fd428da by readingno change under lucene/monitor since 645d4fe14a20

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateCode 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 realOwn 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, mediumIntended 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, highRe-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; verifiedNine 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, highIts 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 validatedGradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,553 words

Finding record

LFV-507, snapshot of 2026-11-06

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 (LockObtainFailedException on the retry, plus the test framework's report of the leaked write.lock channel). With the recommended fix it passes, together with TestMonitorPersistence, TestMonitorReadonly and TestCachePurging (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 main 2f424fd428da has no change under lucene/monitor since 645d4fe14a20 (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 main ea57360912ed, 10.5.2 and 9.12.3.

Lucene and upstream sources

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-134
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:70-78, :87-99, :142-145
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:954, :1202-1209
  • a 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-1209
  • a 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-137
  • a 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)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.