All findings · Lucene finding · LFV-501 · monitor-delete-clear-outside-commit-lock

Monitor.deleteById and Monitor.clear can leave a concurrently registered query partly indexed

If deleteById("q") runs during another thread's register of q = a OR b OR c, q can keep only some of its parts: it stays registered, but a document with only a no longer matches, also after a restart. clear() has the same gap; every release since 8.2.0 is affected.

Summary

Priority
Low, rank 277Needs a delete or clear that races a register of the same query in the optional monitor module, a narrow window.
Impact
silent wrong results
Since
8.2.0 (2019, the monitor module's first release)introduced 2019-06-13 · about 7 years
Affected
every release with lucene-monitor, 8.2.0 to 10.6.0 (17 run, 10.6.0 by reading), and main
Status
documented
Found by
TLA+ model checking of the Monitor's write protocol

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

The finding in 30 seconds

The Monitor indexes a query as one document per part (a OR b OR c gives 3). register adds them one by one under a commit lock; deleteById and clear send their delete to the index before taking that lock, so the delete can land between two adds.

Timeline of the repro on Apache Lucene main. Thread 1 runs register(q = a OR b OR c) and holds the commit lock while it adds three query-index documents, q_0 (a), q_1 (b) and q_2 (c). After q_0 is added, thread 2 runs deleteById("q"), whose delete does not take the lock and removes only q_0; thread 1 then adds q_1 and q_2 and commits. Expected: q complete (3 parts) or gone (0 parts). Actual: getQueryIds=[q], getDisjunctCount=2, a document with a gets 0 matches, b and c match; the same after close and reopen.
Two threads on one Monitor: register adds q's parts one by one under the commit lock; the delete lands after the first part, so the index keeps only the parts for b and c, and a document with a no longer matches. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Register q = a OR b OR c on a default Monitor. The Monitor stores q as three parts, one per clause, and register writes them one after another while holding its commit lock.
  2. While register is writing the second part, another thread calls monitor.deleteById("q"). You expect the two calls to take effect in some order: q ends up complete (3 parts) or gone (0 parts).
  3. Instead the delete does not wait for the lock. It removes the one part already written, and register then writes and commits the other two: getQueryIds() is [q], getDisjunctCount() is 2, and a document with only a gets 0 matches while b and c still match.
  4. No error. The partial query survives close, reopen and purgeCache until q is registered again or deleted. clear() has the same gap for any multi-part register in flight; at the same point it also drops the part being written (deleteAll waits for it), so only the third part survives. Without any test hook, 15-37 of 400 forced deleteById races and 20-53 of 400 clear races on a 64-clause query ended partial in the recorded runs.

Who is affected

Applications of the optional lucene-monitor module, default settings, that change queries from more than one thread: for example a user deleting a saved alert while an edit of it is applied, or a reload calling clear() while another path registers. Any query with two or more parts can be hit; single-threaded updaters are safe.

Mechanism, terms and numbers653 words

The Monitor indexes a registered query as one document per decomposed part, and register adds those documents one by one while holding commitLock. Monitor.deleteById and Monitor.clear send their IndexWriter delete before they take that lock, so a delete or clear that arrives while register is between two addDocument calls removes only the parts already added. The other parts are committed: the query is still reported as registered, but it silently misses every document that only the removed parts match, and for deleteById that was shown to survive close, reopen and cache purges until the id is registered again. Neither order of the two calls can produce this state. It has been in every release with lucene-monitor, from 8.2.0 to 10.6.0 (17 of them run, the rest by code identity, 10.6.0 by reading), and is on main.

Terms

  • Monitor (lucene-monitor): stores queries under ids and matches documents against them (reverse search). Internally it keeps the queries in a small Lucene index, the query index.
  • Decomposition: the default QueryDecomposer splits a disjunction into its clauses. Each part becomes one query-index document with the query's id in the field _query_id and a cache id q_0, q_1, ... (QueryCacheEntry.java:51-60, WritableQueryIndex.java:185-203). Matching searches the query index, so a part without a live document can never match.
  • commitLock: the lock in WritableQueryIndex that serializes changes to the query index (WritableQueryIndex.java:64).

The bug

  1. register calls commitWithoutNotify (WritableQueryIndex.java:107-133). Inside synchronized (commitLock) (:109) it deletes the id's old documents (:117-119), then calls writer.addDocument once per part (:120-125), then commits and refreshes (:127-128).
  2. deleteQueries (behind Monitor.deleteById) calls writer.deleteDocuments(new Term(FIELDS.query_id, id)) (:339-341) before commitWithoutNotify(Collections.emptyList()) takes the lock (:342). clear does the same with writer.deleteAll() (:348).
  3. IndexWriter orders every change by a sequence number, and a term delete applies only to documents indexed before it (IndexWriter.java:117-119, DocumentsWriterPerThread.java:361-383). A delete that arrives while q_1 is being added deletes q_0 and keeps q_1 and every later part.
  4. register then commits the survivors. getQueryIds (an index scan, Monitor.java:276-280) still lists q, getDisjunctCount (numDocs, Monitor.java:257-262) counts the survivors, and match (WritableQueryIndex.java:205-246) can only reach parts that have a document.
  5. On reopen the cache is rebuilt from the stored query, all three parts, but the index still has only q_1 and q_2 (WritableQueryIndex.java:137-181); purgeCache rebuilds the cache from the index (WritableQueryIndex.java:249-258), so neither repairs it (both executed for deleteById). Registering q again (delete all, add all) or deleting it does.
  6. clear() uses deleteAll(), which is not tied to an id, so it can cut short the register of any multi-part query. Its IndexWriter side differs: deleteAll waits for the part being added and then drops every buffered document (IndexWriter.java:2765, DocumentsWriter.java:266-325). In the pinned timing only q_2 survives: getDisjunctCount=1, only c matches. A single-part query cannot end partial: its one add is either before or after the delete.

The numbers

  • Pinned test (q = a OR b OR c, the delete arrives while the second part is indexed): getQueryIds=[q], getDisjunctCount=2, a document with a gets 0 matches. An independent reproducer's test of the same timing also shows b and c matching once each, and a persistent Monitor the same before and after close and reopen, with getQuery("q").getQueryString() = a b c (the query string it passed at register; without a serializer getQuery throws). With clear() in the same timing: getDisjunctCount=1, only c matches.
  • Hook-free stress test (64-clause disjunction, stock presearcher, random 0-3 ms delay before the delete, 400 races per method): 15-37 of 400 deleteById races and 20-53 of 400 clear races ended partial in the recorded runs on main, 10.5.2, 9.12.3 and 8.2.0, for example getDisjunctCount=59 of 64. These are forced-race rates, not field rates.
Exposure and likelihood173 words
  • Who. Embedders of lucene-monitor (alerting, saved searches, routing, classification, content screening) that call register and deleteById (same id) or clear() (any id) from different threads. The documents passed to match() cannot trigger it.
  • Default or opt-in. Default within the module: new Monitor(analyzer) with the default MonitorConfiguration, TermFilteredPresearcher and QueryDecomposer, in memory or persistent. The module itself is optional.
  • Window. The time register needs to index the query's remaining parts, so it grows with the number of parts. A three-clause disjunction suffices.
  • Damage. The query is reported as registered but matches only a subset of what it should. No exception and no log in the executed runs. For deleteById it survives commit, close, reopen and purgeCache until the id is registered again or deleted. deleteById leaves other queries alone, clear() removes them as it should, and nothing over-matches.
  • How likely. Rare: it takes concurrent mutation inside a short window, and no report of it was found since 8.2.0 (2019). An application that applies all query changes from one thread never hits it.

Minimal repro

The test shows that Monitor.deleteById("q") running while register is indexing q = a OR b OR c (a hook pauses register while part f:b is indexed until deleteById is blocked) leaves q registered but only partly indexed.

TestMonitorDeleteByIdDuringRegister.java (JUnit test)107 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.io.UncheckedIOException;
import java.util.Map;
import java.util.function.BiPredicate;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestMonitorDeleteByIdDuringRegister extends LuceneTestCase {

  private Monitor monitor;
  private Thread delete;

  private void deleteWhileIndexing() {
    if (delete == null) {
      delete =
          new Thread(
              () -> {
                try {
                  monitor.deleteById("q");
                } catch (IOException e) {
                  throw new UncheckedIOException(e);
                }
              });
      delete.start();
      while (delete.getState() != Thread.State.BLOCKED && delete.isAlive()) {
        Thread.yield();
      }
    }
  }

  public void testDeleteByIdDuringRegisterLeavesQueryPartlyIndexed() throws Exception {
    Query b = new TermQuery(new Term("f", "b"));
    Presearcher presearcher =
        new Presearcher() {
          final Presearcher in = new TermFilteredPresearcher();

          @Override
          public Query buildQuery(LeafReader reader, BiPredicate<String, BytesRef> termAcceptor) {
            return in.buildQuery(reader, termAcceptor);
          }

          @Override
          public Document indexQuery(Query query, Map<String, String> metadata) {
            Document doc = in.indexQuery(query, metadata);
            if (query.equals(b)) {
              TokenStream hook =
                  new TokenStream() {
                    @Override
                    public boolean incrementToken() {
                      deleteWhileIndexing();
                      return false;
                    }
                  };
              doc.add(new Field("hook", hook, TextField.TYPE_NOT_STORED));
            }
            return doc;
          }
        };
    Query q =
        new BooleanQuery.Builder()
            .add(new TermQuery(new Term("f", "a")), Occur.SHOULD)
            .add(b, Occur.SHOULD)
            .add(new TermQuery(new Term("f", "c")), Occur.SHOULD)
            .build();
    Document docA = new Document();
    docA.add(new TextField("f", "a", Field.Store.NO));
    try (Monitor m = new Monitor(new StandardAnalyzer(), presearcher)) {
      monitor = m;
      monitor.register(new MonitorQuery("q", q));
      delete.join();
      // expected: q matches docA iff q is still registered; actual: registered q misses docA
      assertEquals(
          "matches of docA, getQueryIds=" + monitor.getQueryIds(),
          monitor.getQueryCount(),
          monitor.match(docA, QueryMatch.SIMPLE_MATCHER).getMatchCount());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-501/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/monitor/build/libs/lucene-monitor-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.monitor.TestMonitorDeleteByIdDuringRegister
Expected vs actual
Expected a document with only 'a' to match q exactly when q is still registered (1 match, or q gone). Actual: getQueryIds=[q] but 0 matches (expected:<1> but was:<0>).

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

More files, environment, full output37 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: matches of docA, getQueryIds=[q] expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.monitor.TestMonitorDeleteByIdDuringRegister.testDeleteByIdDuringRegisterLeavesQueryPartlyIndexed(TestMonitorDeleteByIdDuringRegister.java:101)

Why it looks unintended

  • No order of the two calls gives this state: q is listed as registered, yet misses some of its own matches.
  • Every other query-index document change takes the commit lock, and both methods take it right after their delete.
  • The module's author fixed the same kind of race in update() in the predecessor project (2015).
The full argument319 words
  • No explicit promise. The Monitor class javadoc and the register, deleteById and clear javadocs say nothing about thread safety or atomicity (Monitor.java:38-41, :147-205). The contract is implied by the design, not documented.
  • A query is stored as all its parts. getDisjunctCount is "the number of queries (after decomposition) stored in this Monitor" (Monitor.java:257-259), and register replaces all parts of an id under one lock, so two register calls on the same id are already atomic (WritableQueryIndex.java:109-125).
  • One lock for every document change. commitLock (WritableQueryIndex.java:64) guards commitWithoutNotify (:109) and both cache swaps in purgeCache (:294, :305). The two deletes are the only document changes outside it (close() also commits outside it, :321-325). deleteQueries and clear wait for it right after their delete (:342, :349), so the placement buys almost no concurrency.
  • Concurrent use is designed in, at least in part. A background purge thread runs by default (WritableQueryIndex.java:86-98; "This is normally called from a background thread", Monitor.java:131), and ConcurrentQueryLoader is a "Utility class for concurrently loading queries into a Monitor" (ConcurrentQueryLoader.java:35), documented for loading "before starting matches" (:37-38). These show purge-versus-change and register-versus-register concurrency, not register racing delete or clear. Upstream fixed earlier Monitor concurrency bugs (LUCENE-9791 / #10830; #12801, whose description has Monitor.match "called concurrently with Monitor.register/Monitor.deleteById and QueryIndex.purgeCache").
  • Precedent. In Luwak, the predecessor project, flaxsearch/luwak#68 (2015, another contributor's report) showed update() issuing its delete outside the synchronized commit; the maintainer answered "I think you've identified genuine issues" and commit 18fc644 "Move queryindex deletions into synchronized block" fixed update() only. deleteById and clear kept their unlocked deletes and were imported into Lucene in 2019.
  • Tests. No upstream test runs deleteById or clear concurrently with register: in TestCachePurging.doConcurrentPurgesAndUpdatesTest the deletes (TestCachePurging.java:108-110) finish before the second thread starts (:114).
The devil's-advocate check211 words

The strongest "works as designed" case (independent reviewers, a devil's advocate and a cross-model fact check): Monitor never says it is thread-safe, and Java classes with mutable state are not normally presumed thread-safe unless documented; ConcurrentQueryLoader documents only concurrent loading; register(Iterable) already commits big calls in chunks; IndexWriter's add, delete, add order is legal; an application that races register(q) against deleteById(q) cannot know whether q survives anyway; and clear(), a global reset, is something callers may be expected to run while no other writes are in flight. The cross-model reviewer rated this case moderate overall, somewhat stronger for clear().

Why it fails: either serial outcome would be fine, but this is a third outcome, a query reported as registered that silently matches only some of its clauses, persistently. The chunking is per query, so all parts of one query are always written in one locked call: the single query is exactly the unit the design protects. clear() hits a register of any id, so "the application races on the same id" does not hold there; the "quiesce before a reset" expectation is undocumented, but it is the strongest point against the clear() half. What survives is a severity point (a rare trigger), not a validity point. Verdict: a defect.

Suggested fix

Recommended Take commitLock around the delete and the commit in deleteQueries and clear

Take commitLock in deleteQueries and clear around the IndexWriter delete and the commit (commitWithoutNotify re-enters it). Validated: the pinned test and the stress test pass (0 of 400 partial), and the whole monitor module passes with assertions on and off.

public void deleteQueries(List<String> ids) throws IOException {
  synchronized (commitLock) {
    for (String id : ids) {
      writer.deleteDocuments(new Term(FIELDS.query_id, id));
    }
    commitWithoutNotify(Collections.emptyList());
  }
  listeners.forEach(l -> l.afterDelete(ids));
}
// clear(): the same, with writer.deleteAll() inside synchronized (commitLock)
All options and trade-offs4 options · 557 words

Option A was built and validated in a scratch worktree at ea57360912ed; the others are untested alternatives.

Take commitLock around the delete and the commit in deleteQueries and clear (validated by the checks) (recommended)

+11/-5 in WritableQueryIndex.java. Java monitors are reentrant, so the nested commitWithoutNotify call takes the same lock again; the listeners still run after the lock is released. With the fix the race resolves to a serial order: in the pinned test, register then delete (q gone).

public void deleteQueries(List<String> ids) throws IOException {
  synchronized (commitLock) {
    for (String id : ids) {
      writer.deleteDocuments(new Term(FIELDS.query_id, id));
    }
    commitWithoutNotify(Collections.emptyList());
  }
  listeners.forEach(l -> l.afterDelete(ids));
}
// clear(): the same, with writer.deleteAll() inside synchronized (commitLock)

Trade-offs

  • Little concurrency is lost: both methods already wait for commitLock right after the delete; only the delete call itself moves inside, so a concurrent register or purge swap now waits for it. A term delete is cheap; deleteAll() waits for in-flight indexing, aborts buffered documents and merges and waits for pending flushes (IndexWriter.java:2750-2815).
  • The Monitor-level lock order stays commitLock then purgeLock, as in commitWithoutNotify and purgeCache. match() takes only purgeLock's read lock briefly (WritableQueryIndex.java:211-217), so searches do not wait. The IndexWriter locks deleteAll() now takes under commitLock were not analysed beyond the module's tests.
  • It does not serialize close(), which still closes and commits the IndexWriter outside commitLock (WritableQueryIndex.java:321-325).
  • Does not touch the other Monitor bugs (a duplicate id in one register call, a failed register not rolled back), which have other fix sites.

What the checks validated

  • Stock: the pinned test fails with assertions on and off; the stress test fails 2 of 2 (30/400, 42/400).
  • Fixed: the pinned test passes with assertions on and off and with 20 iterations; the stress test passes with assertions on and off (0/400 and 0/400, printed with -Ptests.verbose=true in the fact check's re-run); TestCachePurging, TestMonitor, TestMonitorPersistence and TestConcurrentQueryLoader pass with 5 iterations; the whole :lucene:monitor:test passes with assertions on and off (34 classes, 184 tests, 1 skipped, 0 failures); :lucene:monitor:check -x test passes.
  • Earlier checks: the same fix shadowed into the stock jars passes the pinned test with fresh seeds and the stress test (0 of 400), and all upstream monitor tests pass with it.

Route every query-index write through one locked method (untested)

Give commitWithoutNotify the ids to delete and a "delete all" flag, so that all IndexWriter writes of the Monitor happen in one place under commitLock.

Trade-offs

Same behaviour as the recommended option and harder to break later, but a bigger diff.

Write each id's parts as one IndexWriter block (partial, untested)

In commitWithoutNotify, replace "delete the id, then one addDocument per part" with one writer.updateDocuments(new Term(FIELDS.query_id, id), docsOfId) per id, which "Atomically deletes documents matching the provided delTerm and adds a block of documents" (IndexWriter.java:1594-1596).

Trade-offs

By reading DocumentsWriterPerThread.java:265-301, 361-383, a concurrent term delete then hits all of the block or none of it, which would fix deleteById; but clear()'s deleteAll() would still run outside the lock, it changes how the query index is written, and it was not run.

Document that callers must serialize mutations (not recommended)

Javadoc only.

Trade-offs

Leaves the silent partial state for anyone who misses the note, while the fix is a few lines.

How it was found

TLA+ model checking. While modelling the Monitor's write protocol (decomposition, concurrent deletes and clears, restart), an AI tool found that the invariant "each query is indexed as none or all of its parts" fails when a delete or clear runs during register, and reduced the trace to a JUnit test on the stock jars.

The method in full192 words

Method: TLA+ model checking (TLC, a tla2tools development build of 2026-10-20, rev d2e1472), a hunt unit (Monitor lifecycle and persistence, 2026-10-26).

  • The finder modelled WritableQueryIndex's write protocol with query decomposition, concurrent deleteById and clear, non-aborting add failures and close plus reopen, with a model-to-Java line map. The invariant AtomicQuery (in every idle state each id's indexed documents are none or all parts of one version) is violated for register concurrent with deleteById (counterexample after 359 distinct states) and with clear (after 454). A fixed variant holds (13,440 distinct states); it models a combined fix (the deletes under commitLock plus one atomic updateDocuments per query and cache puts after the add), not the recommended fix alone, which the Java tests validated.
  • Caveat: the model was written while reading the code, and the kept TLC logs are dated after the candidate was first recorded, so the hypothesis may have come from the code reading ("semi-formal" in the hunt's terms).
  • The same unit's exhaustive single-threaded model-based tests (every script of up to 4 operations checked after every step, every 5-operation script at its final state) found 0 mismatches: the bug needs two threads.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, 221 recorded searches over GitHub, the mailing lists, JIRA, the predecessor project's tracker and the web). Closest: a 2015 report of the same race class in the predecessor project's update(), fixed there for that method only.

Searches4 searches

Not fixed upstream either: upstream main, branch_10x, branch_10_6, branch_10_5 and branch_10_4 still delete before taking commitLock (read-only GitHub contents API), and the two open PRs that touch WritableQueryIndex.java (#13948, #16847) do not change deleteQueries or clear. The predecessor-project report (flaxsearch/luwak#68, fixed for update() by 18fc644) is a precedent, not a report of this bug: it never names deleteById or clear, whose unlocked deletes the fix left in place. Gaps: keyword search only; GitHub search does not index PR diffs (covered for open PRs by a changed-file listing); private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, flaxsearch/luwak, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, all of GitHub)2026-10-2632 prior-report-tool queries and 57 GraphQL searches (class, method, field and lock names; race, thread-safe, concurrent, partial, disjunct phrasings; label module:monitor)only related items: #12801, #10830 (LUCENE-9791), #12318, #679, luwak#174
Luwak tracker and open Lucene PRs2026-10-26all 191 Luwak issues and PRs listed; the changed files of all 408 open apache/lucene PRs; all 58 comments of #679luwak#68 (2015, update() only, fixed by 18fc644); #13948 and #16847 touch the file but not deleteQueries or clear
Mailing lists (dev@, java-user@, issues@, commits@) and JIRA2026-10-2640 tool queries and 30 lists.apache.org queries; 8 tool and 7 JQL queries (component modules/monitor, luwak, deleteById, thread safe, race, clear)nothing about this bug
Commits, downstream code and the web2026-10-266 commit queries, 4 downstream-repository queries, 12 web queries from the two tool runs, 18 more web queries (one throttled) and 7 direct GitHub and history lookupsnothing about this bug

History

Present since the module was added to Lucene (LUCENE-8766, 2019, first released in 8.2.0) and unchanged since; the pinned test fails on every release tested from 8.2.0 to 10.5.2 and on main. Not fixed upstream.

Versions tested8 rows

Introduced by 251dbe7cea8 "LUCENE-8766: Add monitor subproject" (master, 2019-06-13; JIRA era, no associated GitHub PR found), shipped first through the same-day branch_8x backport b8773d13ad8 in 8.2.0. The code came from Luwak with the unlocked deletes already in place, so it is not a regression. #679 (9.2.0, through the branch_9x backport 72cf36a2b8d) moved the delete-then-commit sequence into WritableQueryIndex unchanged, together with the listener calls it moved there from Monitor; #12801 (9.9.0) changed only purgeCache; #14885 (10.3.0) made the decomposition order stable, which changes which parts survive in the pinned test (only b before, b and c after), not the bug. The lock placement is identical in all 49 release tags with the module in the history clone (8.2.0-8.8.1, 9.0.0-10.5.2) and on main; with the listener lines the bodies have two variants (8.2.0-9.1.0, 9.2.0-main). 8.8.2-8.11.3 are counted as affected by inference (8.8.1 source, 8.11.4 executed).

Versions and branches executed, with results
Version or commitVerdictResult
8.1.1 not affectedno monitor module (no lucene-monitor artifact)
8.2.0 first affectedpinned test fails 10 of 10 iterations (getDisjunctCount=1, misses a); stress 20/400 and 28/400
8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.8.0, 9.9.0, 10.0.0 affectedpinned test fails (getDisjunctCount=1, misses a)
9.12.3 affectedpinned test fails (getDisjunctCount=1); stress 16/400 and 28/400
10.1.0, 10.2.0, 10.2.2, 10.3.0, 10.3.2, 10.4.0, 10.5.0 affecteda diagnostic variant of the pinned test prints a partial query: getDisjunctCount=1 (only b matches) on 10.1.0-10.2.2, getDisjunctCount=2 (b and c) from 10.3.0
10.5.2 affectedpinned test fails (getDisjunctCount=2); stress 18/400 and 29/400, 20/400 and 30/400
main ea57360912ed affectedpinned test fails with assertions on and off (20 of 20 iterations each, gradle and harness); stress 15-37/400 and 20-53/400 in the recorded runs
main + fix not affectedboth tests and the whole monitor module pass

Independent checks

Every check found it real except one read-only reviewer, whose "not real" was procedural: it could not run Java and agreed with the root cause. An independent reproducer, a devil's advocate and an adjudication ran their own tests; fact checks and a diagram check of this doc followed, and their findings are fixed.

Checks and verdicts470 words

All checks ran on main ea57360912ed unless noted; the reviews from another model family read the code only.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (TLA+ and pinned test) candidateAtomicQuery violated for deleteById and clear; the pinned test fails with assertions on (20 of 20 iterations) and off, on 10.5.2 and 9.12.3, and passes with the fix.
Refute review (OpenAI GPT-5.6, reading only) not real (low, procedural)Could not run Java, so it voted not real on procedure only; it agreed with the root cause and argued that Monitor never promises thread safety. A separate run re-ran the repro: fails.
Independent reproducer realOwn pinned test (in-memory and persistent, also after reopen) and own hook-free stress test, written from the claim: partial queries for deleteById and clear on main and 10.5.2; its own lock fix gives 0 of 400.
'intended' review (OpenAI GPT-5.6, reading only) real (medium); intended case moderateFound no warning, precondition or experimental marker; a separate stress test: 25 of 2000 uncoordinated races partial, 0 with the fix.
Devil's advocate real (medium-high)Built the strongest works-as-designed case and rejected it; re-ran the repro on main and 10.5.2: fails.
Adjudication confirmed (high), silent wrong resultsRe-ran the independent reproduction's pinned test (2 of 2 fail) and stress test (19/400 and 31/400 partial) with fresh seeds.
History, prior reports, security since 8.2.0; new; security nonePinned test on 10 releases plus main, stress test on three releases, 221 recorded prior-report searches, a security assessment.
Registry re-run and audit keepFresh seeds: pinned test fails with assertions on and off; stress 15/400 and 27/400, then 22/400 and 53/400; 0 of 400 with the fix.
This doc reproduced; fix validatedBoth tests through gradle on stock main (fail) and with the fix (pass, plus the whole monitor module and check); the stress test also on 10.5.2 through the harness (20/400, 30/400).
Fact check of this doc (independent, executed) bug holds; 4 precision errors fixedRe-ran the gradle commands (also with a new seed), the stress test, tidy, the fix validation (whole module, -ea and -da) and the pinned test on 8.2.0, 9.12.3, 10.5.2 and main -da x20. New probes: clear() in the pinned timing leaves only q_2; purgeCache and reopen keep the partial state; re-register or delete repairs it.
Fact check of this doc (OpenAI GPT-5.6, reading only) core bug real; wording fixedAgreed with the root cause; 15 factual errors (run attribution, history normalization, validation scope, the security framing) and 16 overclaims, resolved; rated the intended-behaviour case moderate, stronger for clear().
Diagram check mechanism correct; 2 captions fixedEvery code row, value and the order of events matched; two captions (the release range, the stress range) were corrected and the visuals re-rendered.

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

Finding record

LFV-501, snapshot of 2026-11-06

Registry title
Monitor.deleteById and Monitor.clear issue their IndexWriter delete outside the commit lock, so a delete or clear that runs while register is indexing a decomposed query leaves that query partly indexed: it stays registered but silently stops matching what its deleted parts match, also after a reopen
Area
monitor / query index updates (WritableQueryIndex.deleteQueries and clear versus commitWithoutNotify, reached through Monitor.deleteById, Monitor.clear and Monitor.register)
Priority
Low, rank 277: Needs a delete or clear that races a register of the same query in the optional monitor module, a narrow window.
Impact class
silent wrong results
Impact note
Applications of the optional lucene/monitor module, with the default Monitor, MonitorConfiguration, presearcher and decomposer, that call Monitor.deleteById(id) from one thread while another thread's Monitor.register is indexing a query with that id that decomposes into several parts (any disjunction), or call Monitor.clear() while any such register is in flight, can end up with that query only partly indexed and no error: getQueryIds, getQueryCount and getQuery still report it, but documents that only its deleted parts match are silently missed; the partial state is committed, survives close and reopen of a persistent Monitor [exec] and is not repaired by purgeCache (code reading), until the id is registered again or deleted; ordinary scale (a three-clause disjunction suffices), and in hook-free stress tests about 1-8% of the races ended partial, depending on how the two calls were timed.
Summary
One thread registers query q = a OR b OR c, which the Monitor indexes as three parts, one per clause, while another thread calls monitor.deleteById("q") or monitor.clear(); you expect the two calls to take effect in some order, so that afterwards q is either fully registered or gone. Instead the delete goes to the IndexWriter without the commit lock that register holds, lands between register's per-part addDocument calls and removes only the parts already added. q is still reported as registered with its full query, getDisjunctCount is 2, and a document that contains only 'a' no longer matches, also after the Monitor is closed and reopened.
Introduced
251dbe7cea8126cb7ecadc921ba444c63d06c618 (master, 'LUCENE-8766: Add monitor subproject', by GitHub romseygeek; JIRA LUCENE-8766, no GitHub PR, pre-GitHub-PR era; first released in 9.0.0), shipped first through the same-day branch_8x backport b8773d13ad8e249a045862249bf476c30a444461 (8.2.0) [read: git blame on main stops at be991789564 (#679, LUCENE-10422, 2022-03-21), which only moved deleteQueries, clear and commitWithoutNotify into WritableQueryIndex; blaming QueryIndex.java at be991789564~1 lands on the import; a whitespace- and rename-normalized hash of the deleteQueries and clear bodies is identical in every tag 8.2.0-8.8.1 and 9.0.0-10.5.2 and on main]. The unlocked deletes came with the code donated from the predecessor project Luwak, whose commit 18fc644 (2015, luwak#68) had moved only update()'s delete into the synchronized commit. Not a regression (2019-06-13 (both commits))
First affected version
8.2.0 [exec] (first release with the monitor module; 9.0.0 is the first 9.x release); there is no unaffected earlier release: 8.1.1 has no monitor module (no lucene-monitor-8.1.1 artifact on Maven Central)
Affected versions
Every release that ships lucene-monitor, 8.2.0 to 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] the finder's pinned JUnit (deleteById, seed DEADBEEF, -ea) fails on 8.2.0 (10 of 10 iterations), 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.8.0, 9.9.0, 9.12.3, 10.0.0, 10.5.2 and main (8.x and 9.0.0 ran a copy that differs only in the LuceneTestCase import), main also with -da (20 of 20 iterations) and with the registration check's fresh seeds; the independent reproduction's hook-free stress JUnit (64-part disjunction, -da, 400 races per method) leaves partial queries for both deleteById and clear on 8.2.0 (20 and 28 of 400), 9.12.3 (16 and 28), 10.5.2 (18 and 29; the independent reproduction's own run 15 and 24) and main (the independent reproduction 25 and 23, adjudication 19 and 31, registration check 15 and 27). [read] the other releases by code identity, and 8.8.2-8.11.3 by inference between 8.8.1 (same source) and the executed 8.11.4 (the 8.x branch is not in the history clone). Before 10.3.0 the decomposition order depends on the hash seed (#14885 made it stable), which changes which parts survive in the pinned repro (b only instead of b and c) and could let the pinned repro pass on those releases with another seed; the order-independent stress test fails there. clear() was executed only through the stress tests. Not fixed upstream: upstream main and branch_10_4 still issue the deletes before taking commitLock, and no open PR changes deleteQueries or clear (2026-10-26) [read]. Not executed: x86, and each release's own JDK (all runs used JDK 25.0.3)
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 (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
formal: TLA+/TLC
Upstream and prior reports
none found (searched 2026-10-26, read-only; the prior-report record, verdict new; the finder's scripted prior-report search re-read, the follow-up's searches): 221 recorded queries, namely two scripted prior-report search runs (32 GitHub issue and PR queries, 6 commit, 4 downstream, 40 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA-archive), 57 GitHub GraphQL searches over apache/lucene, flaxsearch/luwak, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub, 30 lists.apache.org queries, 7 JQL queries and 17 web queries, plus full listings of all 191 Luwak issues and PRs and of the changed files of all 408 open apache/lucene PRs and all 58 comments of #679. Not fixed upstream: upstream main and branch_10_4 still issue the deletes before taking commitLock (WritableQueryIndex.java last changed 2026-06-09 by #16197, behaviour-neutral), and the two open PRs that touch the file (#13948: populateQueryCache, #16847: numDocs) do not change deleteQueries or clear. Closest prior item, a precedent and not a report of this bug: flaxsearch/luwak#68 (2015, the predecessor project): update()'s delete issued outside the synchronized commit, fixed for update() only by luwak 18fc644. Related context: #12801, #10830 (LUCENE-9791), luwak#174, #12318. Gaps: keyword search only; GitHub search does not index PR diffs (covered for open PRs by the file listing); GitHub's search rate limit was saturated (covered through GraphQL); one web query was throttled; private reports to security@lucene.apache.org are not visible; 'not found' is a search result, not proof.
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26): the pinned test and the hook-free stress test through gradle in a scratch worktree (assertions on and off, seed DEADBEEF) and through the standalone harness (fresh seeds); the fix in the same worktree (both tests, the closest suites with 5 iterations, the whole monitor module with assertions on and off, :lucene:monitor:check -x test), removed afterwards. Release jars: the pinned test on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.8.0, 9.9.0, 9.12.3, 10.0.0 and 10.5.2, a diagnostic variant on 10.1.0, 10.2.0, 10.2.2, 10.3.0, 10.3.2, 10.4.0 and 10.5.0, the stress test on 8.2.0, 9.12.3 and 10.5.2. An independent fact check repeated the gradle runs and the fix validation, ran the pinned test 20 times with assertions off and ran three probes: the pinned timing with clear(), purgeCache and reopen after the race, and re-register or delete afterwards. All runs used OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:64 (at ea57360912ed) (commitLock), :107-133 (commitWithoutNotify: under commitLock, deleteDocuments(Term(query_id, id)) at :117-119, then queries.put and one addDocument per decomposed part at :120-125, commit and maybeRefresh at :127-128), :338-344 (deleteQueries: writer.deleteDocuments before commitWithoutNotify), :347-351 (clear: writer.deleteAll before commitWithoutNotify)
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:38-41 (at ea57360912ed) (class javadoc, no thread-safety statement), :128-136 (purgeCache 'normally called from a background thread'), :152-204 (register, deleteById, clear javadocs, no atomicity statement), :257-279 (getDisjunctCount = numDocs, getQueryCount, getQueryIds read the index); lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60 (decompose: one entry and one index document per part, cacheId id_N); lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:35-52 ('Utility class for concurrently loading queries into a Monitor')
The finding in 30 seconds
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:64 (commitLock), :107-133 (commitWithoutNotify), :185-203 (buildIndexables), :205-246 (search), :249-258 and :268-315 (purgeCache), :137-181 (populateQueryCache), :338-344 (deleteQueries), :347-351 (clear)
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60 (decompose: one entry per part, cache id id_N)
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:152-162 (register), :184-205 (deleteById, clear), :257-280 (getDisjunctCount, getQueryCount, getQueryIds)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-119 (sequence numbers); DocumentsWriterPerThread.java:361-383 (finishDocuments applies concurrent deletes up to docIdUpTo)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2750-2815 (deleteAll; lockAndAbortAll at :2765); DocumentsWriter.java:266-325 (lockAndAbortAll; filterAndLock at :297, waitForFlush at :308)
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryIndex.java:62-66 (getQuery throws without a serializer)
Who is affected
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:56-58 (default TermFilteredPresearcher); lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:205-246 (match reads the index only)
Why it looks unintended
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:38-41, :131, :147-205, :257-259
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:64, :86-98, :109-125, :294, :305, :342, :349
  • lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:35
  • lucene/monitor/src/test/org/apache/lucene/monitor/TestCachePurging.java:81-127 (deletes :108-110 before startUpdating :114)
  • lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:37-38 ("before starting matches"); WritableQueryIndex.java:321-325 (close)
Suggested fix
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:109-110, :211-217, :294-314 (lock order)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1594-1596; DocumentsWriterPerThread.java:265-301, :361-383
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2750-2815 (deleteAll); DocumentsWriter.java:266-325 (lockAndAbortAll); lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:321-325 (close)

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

Repro files

About the diagrams

The diagram (bug.png) is the timeline of the pinned test: the two threads, the commit lock and the query-index contents after each step. Its numbers come from the pinned test's executed output on ea57360912ed.

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.