All findings · Lucene finding · LFV-772 · monitor-register-publishes-cache-before-searcher-refresh

Monitor.match can silently miss a query while Monitor.register is replacing it

While one thread re-registers query q (apple OR banana becomes cherry OR apple), a Monitor.match of a document apple can return no queries, although both versions match it, because register updates the query cache before the new query index is searchable. Reproduced by default on 12 releases, 8.2.0-10.5.2, and main.

Summary

Priority
Medium, rank 70A default-config Monitor silently misses a re-registered query during a concurrent match, which a real alerting pattern can plausibly hit, but each miss is transient and limited to the ids being edited, so it follows its Monitor race sibling LFV-501 among the Medium Monitor silent rows.
Impact
silent wrong results
Since
8.2.0 (2019, the module's first release, LUCENE-8766)introduced 2019-06-13 · about 7 years
Affected
8.2.0 (the module's first release) to 10.5.2 and main: 12 releases and main executed, the same code in all 48 release tags read; default configuration
Status
documented
Found by
controlled-concurrency exploration (Fray) with a linearizability oracle, aimed by code reading (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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 cache entry and the index document of a clause share one id, the query id plus the clause's position. register changes the cache entry before the new index document is searchable, so for a moment an old index document points at a different clause of the new version.

Timeline of two threads on one Monitor. The updater re-registers q as cherry OR apple: it deletes q's old documents (buffered), then for each clause overwrites the cache entry and adds the document (q_0 = f:cherry, then q_1 = f:apple), then commits and refreshes. The matcher, while the updater is inside register, searches the old query index, finds the old q_0 document indexed under apple, looks q_0 up in the cache, runs f:cherry, gets no match and returns an empty result; before and after register the same match returns q.
Register and match on one timeline: the cache already holds the new q_0 = cherry while the searcher still has the old documents, so the match runs cherry for a document indexed under apple and reports nothing; after the commit and refresh it reports q again. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You register q = apple OR banana. The Monitor keeps each clause twice: as a document in its query index (q_0 indexed under apple, q_1 under banana) and in an in-memory cache under the same ids (q_0 = apple, q_1 = banana).
  2. Another thread edits q: register(q = cherry OR apple). The ids come from clause positions, so now q_0 = cherry, q_1 = apple. register overwrites the cache first; the new index documents become searchable only at the end, after its commit and refresh.
  3. Meanwhile you match a document apple. You expect q: the old and the new version both match it, and every order of the two calls reports q.
  4. Instead the match returns nothing. The old index still finds q_0 (indexed under apple), the cache now says q_0 is cherry, which does not match, and the new q_1 = apple is not searchable yet. Once register returns, the same match reports q again.
  5. No test hooks are needed: while one thread re-registers q 400 times back to back, 24,883 of 26,128 concurrent matches (95%) miss q; an edit that keeps apple at q_0 misses 0 of 11,067.

Who is affected

Applications of the optional lucene/monitor module (default configuration; a configured on-disk monitor too) that match documents while another thread edits an existing query id, such as an alerting service whose users edit standing queries. Only the ids in the register call are missed, only by matches that overlap that call, and only when the edit moves the matching clause.

Mechanism, terms and numbers691 words

A writable Monitor keeps each registered query twice: its clauses as documents in an internal query index, and the same clauses in an in-memory cache keyed by query id + _ + the clause's position. Monitor.register replaces a query by id, but it writes the new version's clauses into the live cache before its new index documents are committed and the searcher is refreshed. A Monitor.match in between pairs the old searcher with the updated cache, so when the edit moves the matching clause to another position, an old index document resolves to a different clause of the new version and the query is silently dropped, although both versions match the document. It is transient (matches that overlap one register call) and limited to the ids in that call, needs no hooks, and reproduces on 12 releases from 8.2.0 to 10.5.2 and on main.

Terms

  • Monitor: the lucene/monitor module matches each incoming document against a set of registered (standing) queries: register(MonitorQuery) adds or replaces a query by id, match(Document, MatcherFactory) returns the ids of the queries that match.
  • Query index and presearcher: the Monitor splits each query into clauses (QueryDecomposer; in clause order since #14885) and indexes one document per clause in an internal index, under the clause's terms. A match first searches that index with the document's terms (the presearcher) to pick candidate clauses.
  • Query cache and cache id: the clauses themselves sit in an in-memory map. The key, the cache id, is the query id + _ + the clause's position (QueryCacheEntry.java:56), so every version of q uses q_0, q_1, ... Each candidate document carries its cache id; the matcher looks the clause up in the map and runs it against the document.

The bug (main a9779ef3c193, WritableQueryIndex.java)

  1. commitWithoutNotify (:107-133, under the commit lock and the purge read lock) deletes the old documents of the ids (:117-119), then for each new clause puts the cache entry into the live map (:121) and adds its document (:122); writer.commit() (:127) and manager.maybeRefresh() (:128) come last. The searcher is an NRT SearcherManager over the writer (:79): only the refresh makes the deletes and new documents visible.
  2. search() (:206-219) takes the current searcher and the same live map together under the purge read lock (:211-217) and uses both for the whole search; the collector resolves each hit with queries.get(cache_id) (:384), and StandardQueryCollector runs that clause (Monitor.java:294-303).
  3. Between the first put and the moment the refresh swaps in the new searcher, a match that starts gets the old searcher, which holds the old q_0 document (indexed under apple) while the map says q_0 = f:cherry. The presearcher picks q_0 for a document apple, the matcher runs f:cherry, no match; the new q_1 = f:apple is not in the old searcher. An executed probe shows the matcher running exactly one candidate, f:cherry, with the registering thread parked at the start and at the end of the IndexWriter commit; an independent repro shows it parked inside addDocument and inside Directory.sync. A match that took the old searcher keeps it until it finishes, even if register returns meanwhile (by reading).
  4. Matches that captured the map before register started and are still collecting can see the overwrite too (a first fix attempt that moved the put later but still changed the live map kept failing).
  5. Only false negatives (every test) and only the ids in the call (by reading, for one re-registration of one id): a hit runs the old or the new version's clause, and the put touches only the cache ids of the queries in the call (in a multi-id register(Iterable), every id of that batch). Not characterized: a long match that spans several re-registrations, and entries above a shorter version's clause count, which stay in the map until a purge.
  6. Moving the put after the refresh only reverses the window (new searcher, old map); the map and the searcher have to be published together (by reading).
Exposure and likelihood353 words
  • Who is hit. Code that calls Monitor.match while another thread calls Monitor.register for an existing id: a percolation or alerting service that keeps matching a document stream while users edit standing queries. Upstream #12801 treats match running concurrently with register/deleteById and purgeCache as a use that must stay fast ("they should be able to execute concurrently"); its demo re-registers existing ids while matching.
  • Default configuration. new Monitor(analyzer) with the in-memory directory (MonitorConfiguration.java:41), the default TermFilteredPresearcher (Monitor.java:56-57, :76-77) and decomposer. An on-disk monitor is not the default (setIndexPath or setDirectoryProvider) but shares the code path: the doc's test with setIndexPath and a serializer fails 2 of 3 with assertions on and off, the control passes (fact check). The read-only Monitor has no register path and was not examined.
  • What it takes. A document that both versions match and an edit that moves the matching clause to another position: a new leading clause (apple to cherry OR apple), a reordered or shortened disjunction. Before 10.3.0 positions came from hash order, so which edits move a clause depended on the JVM's hash seed.
  • Effect. A silent missed match: no exception and nothing in MatchingQueries.getErrors(). Other queries are not affected (by reading). After register returns, the same match reports the query again.
  • Window size. One register call: an addDocument per clause, a commit (with fsync on disk directories) and a refresh that rebuilds the presearcher's term filter over every term of the query index (QueryIndex.java:142-156). In a short probe on stock main (in memory, shared host), re-registering one query took a median of about 5-6 ms with 10,000 standing queries and 18-24 ms with 100,000. A bulk register(Iterable) commits once it holds more than 5,000 updates, so a full batch is 5,001 queries (Monitor.java:152-162, MonitorConfiguration.java:35), and the window covers a whole batch (by reading).
  • Frequency. No field rate was measured. The 83-96% miss rates of the stress tests come from an updater that is always inside register, not from real traffic; nobody has reported the bug since 8.2.0 (2019).

Minimal repro

The test shows that a Monitor.match on another thread while Monitor.register re-registers q (apple OR banana -> cherry OR apple) and is inside IndexWriter.commit() misses q (TestMonitorMatchDuringReRegister#testMatchOnOtherThreadWhileReRegisterCommits, one document).

TestMonitorMatchDuringReRegister.java (JUnit test)91 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.util.concurrent.FutureTask;
import java.util.concurrent.atomic.AtomicReference;
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.IndexWriterConfig;
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.InfoStream;

public class TestMonitorMatchDuringReRegister extends LuceneTestCase {

  public void testMatchOnOtherThreadWhileReRegisterCommits() throws Exception {
    Document doc = new Document();
    doc.add(new TextField("f", "apple", Field.Store.NO));
    AtomicReference<Monitor> matchAtCommit = new AtomicReference<>();
    AtomicReference<Boolean> matchedDuringRegister = new AtomicReference<>();
    InfoStream infoStream =
        new InfoStream() {
          @Override
          public void message(String component, String message) {
            Monitor monitor =
                message.equals("commit: start") ? matchAtCommit.getAndSet(null) : null;
            if (monitor != null) {
              FutureTask<Boolean> match =
                  new FutureTask<>(
                      () -> monitor.match(doc, QueryMatch.SIMPLE_MATCHER).matches("q") != null);
              new Thread(match).start();
              try {
                matchedDuringRegister.set(match.get());
              } catch (Exception e) {
                throw new RuntimeException(e);
              }
            }
          }

          @Override
          public boolean isEnabled(String component) {
            return true;
          }

          @Override
          public void close() {}
        };
    MonitorConfiguration config =
        new MonitorConfiguration() {
          @Override
          protected IndexWriterConfig getIndexWriterConfig() {
            return super.getIndexWriterConfig().setInfoStream(infoStream);
          }
        };
    try (Monitor monitor = new Monitor(new StandardAnalyzer(), config)) {
      monitor.register(new MonitorQuery("q", or("apple", "banana")));
      matchAtCommit.set(monitor);
      monitor.register(new MonitorQuery("q", or("cherry", "apple")));
      // expected: a match while register() commits reports q (both versions match); actual: none
      assertEquals(
          "q matched 'apple' during re-register", Boolean.TRUE, matchedDuringRegister.get());
    }
  }

  private static Query or(String a, String b) {
    return new BooleanQuery.Builder()
        .add(new TermQuery(new Term("f", a)), Occur.SHOULD)
        .add(new TermQuery(new Term("f", b)), Occur.SHOULD)
        .build();
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-772/, 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.TestMonitorMatchDuringReRegister
Expected vs actual
Expected: the match of 'apple' reports q, since both versions match it. Actual: no match (the cache already maps q_0 to cherry while the searcher still has the old apple document); fails with -ea and -da.

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

More files, environment, full output38 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: q matched 'apple' during re-register expected:<true> but was:<false>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.apache.lucene.monitor.TestMonitorMatchDuringReRegister.testMatchOnOtherThreadWhileReRegisterCommits(TestMonitorMatchDuringReRegister.java:80)

Why it looks unintended

  • search() takes the searcher and the cache together under a lock on purpose; register breaks that pairing by changing the shared cache early.
  • Upstream treats matching during updates as normal (#12801) and fixed missed matches under concurrency as a bug (LUCENE-9791).
  • The result matches no state: q matches before, after, and in every serial order.
The full argument385 words
  • The pairing is deliberate. search() takes manager.acquire() and the map together under the purge read lock (WritableQueryIndex.java:211-217). That is the fix of luwak#68 case 2 in the predecessor project (2015, "Get searcher and query cache within a purge lock"). register mutates the shared map before its documents are visible, so the pair no longer belongs to one state.
  • Concurrent match and register is an anticipated use (by the design and the upstream history; no javadoc promises it). The implementation note at WritableQueryIndex.java:276-290 arranges the locks so searches do not wait for commits: "...searches waiting on purge waiting on commit which is problematic since commit is slow and search cannot be slow." #12801 (CHANGES: "Remove possible contention on a ReentrantReadWriteLock in Monitor which could result in searches waiting for commits") was about "multi-second latencies in the Monitor.match function when it is called concurrently with Monitor.register/Monitor.deleteById and QueryIndex.purgeCache". purgeCache "is normally called from a background thread" (Monitor.java:131). The module's author wrote in luwak#174: "You should only have a single Monitor object, but you can index and query it from multiple threads".
  • Missed matches under concurrent use were fixed as bugs. LUCENE-9791 (#10830) is recorded as "fixing a Luwak/Monitor bug causing registered queries to sometimes fail to match" (lucene/CHANGES.txt:5386-5388). It concerned match calls running concurrently with each other (BytesRefHash#find), not register.
  • Replacing by id is the tested update path: TestMonitor.testUpdatesOverwriteOldQueries (TestMonitor.java:71-86) and the delete-by-id loop in commitWithoutNotify.
  • No state gives this answer. Both versions match the document, q matches before and after, and every serial order reports q; a stale-but-consistent answer would still report it. This assumes register takes effect at one point (linearizable), the model of the controlled-concurrency oracle and the test; without a documented contract a maintainer could call an in-flight replacement a state in which neither version is guaranteed.
  • Origin, not design. The predecessor library (Luwak) used the same put-first order but keyed cache entries by a content hash of the query and its metadata plus _ + the position, so a new version never overwrote an entry the old searcher used. The port (LUCENE-8766) switched to id + position keys when the Monitor began taking Query objects directly and kept the order; its JIRA thread does not discuss matches during updates.
The devil's-advocate check167 words

The strongest works-as-designed case (raised by the cross-model reviewers and the devil's-advocate checker): no javadoc says Monitor is thread-safe or that a match sees a snapshot (Monitor.java:38-41, register, match, package-info); ConcurrentQueryLoader.java:37-38 says "You can use multiple threads to parse and index queries before starting matches"; StandardQueryCollector silently skips a missing cache entry (Monitor.java:296); a read-only Monitor returns stale results until it refreshes (TestMonitorReadonly).

Why it fails: the ConcurrentQueryLoader sentence describes that helper's startup use, not a precondition of register, and the lock design and #12801 treat matching during updates as normal, and LUCENE-9791 treated missed matches under concurrency as a bug; the null guard covers a missing entry, while here the entry is present but belongs to the other version; read-only staleness is a consistent old state, while this answer matches no state. What remains is a contract-wording point (the contract is implied by the design, not documented) and a severity point (transient), not a validity point.

Suggested fix

Recommended B. Per-generation cache map

Give each searcher generation its own cache map: a commit builds a copy with the new entries instead of changing the live map, the searcher factory attaches it to the new reader, and search() uses the map of the searcher it acquired. The repros and the module's 184 tests pass; in a short probe, match latency stayed at stock levels.

// commitWithoutNotify: copy on write instead of mutating the live map
ConcurrentMap<String, QueryCacheEntry> next = new ConcurrentHashMap<>(this.queries);
for (Indexable update : indexables) {
  next.put(update.queryCacheEntry.cacheId, update.queryCacheEntry);
  writer.addDocument(update.document);
}
this.queries = next;
writer.commit();
manager.maybeRefresh(); // the searcher factory binds this.queries to the new reader

// search(): resolve hits with the map bound to the acquired searcher
searcher = manager.acquire();
queries = cacheByReader.get(searcher.getIndexReader().getReaderCacheHelper().getKey());
All options and trade-offs4 options · 841 words

Two variants were implemented and validated in a scratch checkout of main; both keep the commit outside the purge write lock, as #12801 requires. A third design and a non-fix are listed for completeness.

B. Per-generation cache map (validated by the checks) (recommended)

+39/-5 in WritableQueryIndex.java. A commit copies the map, adds the new entries (and logs them in the purge's register log as today) and sets this.queries = next instead of mutating the live map. The SearcherFactory (a TermsHashBuilder subclass) binds the current map to each new reader in a ConcurrentHashMap<IndexReader.CacheKey, Map<...>> with a closed listener, the same way termFilters is kept. search() resolves its hits with the map bound to the searcher it acquired. purgeCache rebinds the current reader to the purged map under the commit lock, so no refresh can run in between. A bound map is never changed.

// commitWithoutNotify: copy on write instead of mutating the live map
ConcurrentMap<String, QueryCacheEntry> next = new ConcurrentHashMap<>(this.queries);
for (Indexable update : indexables) {
  next.put(update.queryCacheEntry.cacheId, update.queryCacheEntry);
  writer.addDocument(update.document);
}
this.queries = next;
writer.commit();
manager.maybeRefresh(); // the searcher factory binds this.queries to the new reader

// search(): resolve hits with the map bound to the acquired searcher
searcher = manager.acquire();
queries = cacheByReader.get(searcher.getIndexReader().getReaderCacheHelper().getKey());

Trade-offs

  • One map copy per register call (or per batch of about 5,000 in a bulk call). In a short probe (100,000 standing queries, q re-registered 200 times back to back, one matching thread, two runs on a loaded shared host): register median 26-35 ms vs 18-24 ms on stock; match p99 0.10-0.12 ms vs 0.13-0.23 ms on stock. Indicative only, not a benchmark.
  • While an old searcher is in use, its map stays alive too (one extra map of pointers per open generation).
  • New code: validated by tests and the probe, not by the controlled-concurrency exploration that variant A got. Not tested: failure paths (an addDocument, commit or maybeRefresh that throws), the interaction with LFV-499, and a hook-free run on disk.

What the checks validated

  • The new test passes (gradle with assertions on; standalone with assertions on and off, 30 of 30 runs with 10 iterations); the hunt's JUnit, the independent reproducer's window test (parks inside addDocument and Directory.sync) and the hook-free stress test (0 of 34,413 and 0 of 26,373 misses) pass.
  • The whole monitor module: 33 suites, 184 tests (181 upstream + 3 new), 0 failures, assertions on and off; the closest suites with nightly tests (29 tests); :lucene:monitor:check -x test (google-java-format, ecjLint, forbiddenApis).
  • The independent fact check re-ran the module (184 tests, assertions on and off), check, the new test (standalone, assertions off) and the hook-free stress test (0 of 14,767 misses).

A. Publish the new map together with the refresh, under the purge write lock (validated by the checks)

+18/-3 in WritableQueryIndex.java (the hunt's sketch). addDocument and commit stay under the read lock; then, under the write lock that search() takes as a read lock, the commit copies the map, adds the new entries (and logs them during a purge), publishes it and calls maybeRefresh().

Trade-offs

  • Searches wait for every refresh, which rebuilds the term filter over every term of the query index (QueryIndex.java:142-156). In the same probe: match p99 21-34 ms (max 74-103 ms) vs 0.13-0.23 ms on stock; register median 27-35 ms. That is the kind of search stall #12801 removed.
  • Same map copy per register call as B.
  • Failure path (by reading, not tested): this.queries = next is set before maybeRefresh(); if the refresh throws, the old searcher stays current with the new map until the next successful refresh.

What the checks validated

  • The new test (standalone with assertions on and off, gradle with them on; no 10-iteration run), the hunt's JUnit, the hook-free stress test (0 of 27,629 misses), the module's 184 tests (assertions on and off), the nightly closest suites and check; the independent reproducer's window test passes with its own sketch, the same code with other comments.
  • Controlled-concurrency exploration (Fray) of the real Monitor, on ea57360912ed jars (the monitor module is identical): 0 violations in 420 scenarios (12,200 schedules), 160 with this fix alone and 260 with it plus a mask for the separate LFV-501 race; without the fix the same scenario seeds violate in 27 of 100 (stock) and 21 of 100 (LFV-501 mask only).

C. Version-specific cache ids (not implemented)

Give each version's entries keys that the old searcher's documents cannot collide with, as the predecessor library's content-hash keys did; old entries then live until the next purge.

Trade-offs

On-disk monitors rebuild the cache at open by re-decomposing the stored queries (WritableQueryIndex.java:137-181), so the key must be re-derivable and stable across JVMs (#14886 is the caution), and existing indexes store id_N ids.

Not a fix: move the put after the refresh, or strengthen the null guard

Putting the entries after maybeRefresh() reverses the window (a new searcher with the old map). The null guard in StandardQueryCollector does not help: the entry is present but belongs to the other version.

Trade-offs

Both by reading; not executed.

Downstream note

Downstream, a Monitor-based alerting or percolation service can silently miss an alert for a standing query while a user is editing it.

How it was found

A code-reading lead (a search's cache map and searcher must come from one generation) was tested by controlled-concurrency exploration (Fray) of the real Monitor against an exact linearizability oracle: 19 of 60 two-thread register-and-match scenarios had no valid serial explanation. It was then reduced to a deterministic public-API JUnit.

The method in full214 words

Method: controlled-concurrency exploration with a linearizability oracle (the hunt's "semi-formal" tier), in a hunt unit on Monitor query-index concurrency.

  • The plan's lead came from code reading: the cache snapshot and the searcher a search uses should belong to one generation (WritableQueryIndex.java:121-128 versus :211-215).
  • Lincheck, the planned model checker, could not run Lucene's indexing under its instrumentation (a tool limitation). The finder used Fray 0.9.0 (POS, PCT and random schedulers) on the stock jars: one writable Monitor, 2-3 threads running random register, match, delete, clear, purge and getQueryIds operations, and an exact linearizability oracle against a sequential model (state = the registered version per id; match(doc) = the ids whose version has a term of doc).
  • Results: 19 of 60 register-and-match scenarios violate the oracle on the stock jars, 27 of 100 with purges and getQueryIds, 21 of 100 with every operation (with a fix for the separate delete/clear race masked in); 0 of 420 scenarios (12,200 schedules) with fix variant A. An unscheduled plain run of the same harness also hit it once.
  • The finder then wrote a deterministic JUnit (an InfoStream hook parks register at the IndexWriter commit), and an independent reproducer, reviewers from another model family (OpenAI GPT-5.6, reading only), a devil's advocate, an adjudication and an audit checked it.

Prior reports, history and checks

Prior reports

No prior report found in 288 queries over GitHub issues, PRs and code, the JIRA archive, the mailing lists and the web (2026-10-27). Closest: #14886 (same symptom from a different cause, fixed by #14885) and luwak#68 (other update-versus-match races, fixed).

Searches2 searches

Verdict new, confidence medium-high. A reviewer may raise the #679 review comment; it concerns the opposite order in a draft read-only index and supports the contract.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs, review comments and commits; flaxsearch/luwak; apache/solr, OpenSearch, Elasticsearch; global code search2026-10-2748 queries through a scripted prior-report search, 37 further GitHub searches (the module:monitor label, migrated LUCENE issues, WritableQueryIndex, commitWithoutNotify), 9 commit and 6 downstream queries, the changed files of all 408 open PRsno report; related #679 review (reverse order in a draft read-only index), #14886 / #14885 (cross-JVM clause order), #12801 (latency), #10830 (LUCENE-9791), luwak#68, luwak#40, luwak#74, luwak#174, apache/solr#2382
JIRA archive, dev@, java-user@, issues@, commits@, builds@, the web2026-10-2712 JIRA, 129 mailing-list and 37 web queries; 10 groups of direct reads (LUCENE-8766 thread, Luwak sources)no report

History

Present since the module was added (LUCENE-8766, 251dbe7cea8 and b8773d13ad8, 2019-06-13; first release 8.2.0), so no release is unaffected. Reproduced on 12 releases from 8.2.0 to 10.5.2 and on main; not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-8766 "Add monitor subproject" (GitHub #9811 after the migration; by GitHub romseygeek, 2019-06-13): 251dbe7cea8 on master (first released in 9.0.0) and the same-day b8773d13ad8 on branch_8x (first released in 8.2.0). The put before addDocument/commit/maybeRefresh, search() capturing the searcher with the live map, and id + position cache ids are all in the first commit. 8.1.1 has no monitor module.

Later changes kept the order: LUCENE-9570 (format), LUCENE-9796 (lookupOrd), #679 (LUCENE-10422, 9.2.0, moved the code verbatim into WritableQueryIndex.commitWithoutNotify), #12801 (9.9.0, purge locks inside the commit lock), #14885 (10.3.0, clause-order decomposition). The version-portable check fails on both sides of each. Not fixed upstream: the last monitor commit on main is df6f71a3729 (2026-09-25, formatting only; read-only API check, 2026-10-27); branch_10x and branch_10_5 carry no fix (local clone fetched 2026-10-26); no open PR changes commitWithoutNotify or search (408 open PRs listed 2026-10-27).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 2 of 3 (gradle and standalone, assertions on and off; 20 of 20 bug runs in 10 iterations); the control passes; hook-free stress 95% misses
10.5.2 affectedthis doc's test fails 2 of 3; the version-portable check fails with assertions on and off
10.0.0, 10.2.2, 10.3.0 affectedversion-portable check fails, control passes; the hunt's JUnit fails on 10.2.2 and 10.3.0 (#14885 sits between them)
9.0.0, 9.1.0, 9.2.0, 9.8.0, 9.9.0, 9.12.3 affectedversion-portable check fails, control passes (9.12.3 also with assertions off); the hunt's JUnit fails on 9.1.0 and 9.12.3
8.2.0, 8.11.4 affectedversion-portable main() check fails (8.2.0 also with assertions off); 8.2.0 is the first release with the module
8.1.1 and earlier no moduleno monitor module (no lucene-monitor-8.1.1 artifact)
main + fix B or fix A fixedthe new test, the hunt's JUnit, the hook-free stress test and the module's 184 tests pass with either fix (the exact runs per variant are under Suggested fix)

Independent checks

Confirmed by an independent reproducer with its own park points, a devil's advocate, an adjudication and an audit; a reviewer from another model family (OpenAI GPT-5.6, reading only) called it real. The refute-lens review said "not real" with low confidence, procedurally, because it could not run code, and agreed on the root cause.

Checks and verdicts445 words

Each executing check ran its own or the hunt's test on the stock jars; every control passes.

Each independent check and its verdict
CheckVerdictWhat it found
finder (Fray + JUnit) candidateFray on stock jars: 19 of 60 two-thread scenarios violate, 0 of 420 with fix A; deterministic JUnit fails on main, 10.5.2 and 9.12.3, a main() variant on 8.11.4 and 8.2.0.
independent reproducer realOwn JUnit with other park points (inside addDocument, inside Directory.sync): 3 of 4 fail on main (assertions on and off), 10.5.2 and 9.12.3, each before=[q] during=[] after=[q]; control passes. Hook-free stress: 83-96% misses, control 0; own fix passes.
cross-model review, refute lens not real, low (procedural)Read the code only and could not reproduce; agreed on the root cause and that the inputs are in contract. Its argument: no thread-safety javadoc, and ConcurrentQueryLoader says "before starting matches". A separate run re-ran the repro: 2 of 2 fail.
cross-model review, intended lens real, mediumRead the code only; root cause agrees. A separate stress test missed q in 68,521 of 73,485 matches, the control in 0 of 203,837, and 0 with the fix.
devil's advocate real; not intendedBuilt the strongest works-as-designed case and rejected it; re-ran the repro on main and 10.5.2; own hook-free probe: 192,057 of 203,145 matches missed, control 0 of 137,013.
adjudication confirmed, highRe-ran the independent reproduction's window test (3 of 4 fail, control passes) and stress test (19,132 of 20,973 missed; 0 with the fix).
registry re-runs and audit keepFresh seeds on main: the hunt's JUnit fails 2 of 2 with assertions on and off and passes with fix A; the independent reproduction's stress test misses 19,435 of 20,575.
this doc's runs reproduced; two fixes validatedNew 3-test JUnit: 2 fail on main (gradle and standalone, assertions on and off) and 10.5.2; fix variants A and B each pass it, the other repros and the monitor module.
independent fact check holds after two wording fixesRe-ran the gradle command (3 tests, 2 failed, assertions on and off), the final test file on main and 10.5.2 (2 of 3 fail each, assertions on and off), the stress test (32,008 of 33,961 missed), an on-disk variant (2 of 3 fail) and both fixes (module 184 tests, 0 failures). Found two misattributed upstream sources, fixed here.
cross-model fact check sound; 3 wording errors, 8 overclaimsOpenAI GPT-5.6, reading only, against saved command outputs: the race, the traces and the runs hold; it found 3 factual errors (0 high), 8 overclaims and 5 missing caveats, all addressed in the brief and this doc.

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

Finding record

LFV-772, snapshot of 2026-11-06

Registry title
Monitor.match running while Monitor.register re-registers the same query id can silently miss that query, because register puts the new version's cache entries into the live cache map before the new index documents are committed and the query index searcher is refreshed
Area
monitor / query index updates versus matching (WritableQueryIndex.commitWithoutNotify versus search, reached through Monitor.register and Monitor.match)
Priority
Medium, rank 70: A default-config Monitor silently misses a re-registered query during a concurrent match, which a real alerting pattern can plausibly hit, but each miss is transient and limited to the ids being edited, so it follows its Monitor race sibling LFV-501 among the Medium Monitor silent rows.
Impact class
silent wrong results
Impact note
Applications of the optional lucene/monitor module, with the default Monitor, MonitorConfiguration, presearcher and decomposer (in-memory or persistent), that call Monitor.match while another thread re-registers an existing query id with Monitor.register can get a match that silently omits that query: false negatives only, for the ids being re-registered (other ids are not touched, by code reading), and transient, from the first cache put until manager.maybeRefresh (the addDocument calls, the commit with its fsync on disk directories, and the refresh), also for matches that captured the map before register started and are still collecting; it needs a document that both versions match and a matching clause whose position (cache id queryId_N) changes between the versions, for example a new leading clause or a reordered disjunction (before 10.3.0 the position came from hash order); ordinary scale and no hooks (hook-free stress tests miss q in 83-96% of the overlapping matches, a rate that reflects an updater that is always inside register, not real traffic); the typical consequence is a missed alert in a percolation or alerting service that edits standing queries while matching.
Summary
Query q = apple OR banana is registered in a Monitor; one thread re-registers q as cherry OR apple while another thread matches a document that contains apple. You expect the match to report q, because both versions of q match the document and every serial order of the two calls reports it. Instead the match can return no queries: register has already replaced the cache entry q_0 with the new first clause (cherry) while the searcher still holds the old index documents, so the old document for apple resolves to the cherry clause, which does not match, and the new apple clause is not searchable yet; once register returns, the same match reports q again.
Introduced
251dbe7cea8126cb7ecadc921ba444c63d06c618 (master, 'LUCENE-8766: Add monitor subproject', by GitHub romseygeek; JIRA LUCENE-8766, migrated as #9811; the review PR apache/lucene-solr#648 was closed as merged because the change was committed directly; first released in 9.0.0), shipped first through the same-day branch_8x backport b8773d13ad8e249a045862249bf476c30a444461 (8.2.0; its QueryIndex.java and QueryCacheEntry.java are identical to master) [read: git log -S on 'this.queries.put(update.queryCacheEntry.cacheId', 'manager.maybeRefresh()' and 'queries = this.queries' returns only 251dbe7cea8 and be991789564 (#679, LUCENE-10422, which moved the code verbatim into WritableQueryIndex.commitWithoutNotify); the later commits on these lines (LUCENE-9570 reformatting, LUCENE-9796, #12801, #14885) changed no ordering]. Not a regression: the defect is in the module as first committed. The predecessor project Luwak used the same put-before-commit order but keyed cache entries by an MD5 content hash of the query and its metadata plus '_' + position, so a new version never overwrote an entry the old searcher still used; the port switched to id + '_' + position keys when the Monitor began taking Query objects directly [read: Luwak sources and the LUCENE-8766 JIRA thread, not executed] (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 [exec])
Affected versions
Every release that ships lucene-monitor from 8.2.0 to 10.5.2 (the last release before 10.6.0, which was not checked), and main a9779ef3c193. [exec] the follow-up's version-portable main() check (public API; it parks register at IndexWriter 'commit: start' through the query index InfoStream, picks the clause terms with each release's own QueryDecomposer and carries a position-stable control) fails 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, 10.2.2, 10.3.0, 10.5.2 and main with -ea (8.2.0, 9.12.3, 10.5.2 and main also with -da), always before=true, during=false, after=true, while the controls pass, and it passes on main with the fix sketch shadowed; these include the releases on either side of each later refactor (9.1.0/9.2.0 for #679, 9.8.0/9.9.0 for #12801, 10.2.2/10.3.0 for #14885); the candidate JUnit fails 2 of 2 on 9.1.0, 9.12.3, 10.2.2, 10.3.0, 10.5.2 and main (-ea; main also -da); it cannot run on 8.x (no org.apache.lucene.tests package before 9.1.0), and on 9.1.0-10.2.2 its fixed queries move the matching clause only for some hash seeds (they do with the fixed seed DEADBEEF); the independent reproduction's own JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's re-run fails on 10.5.2. [read] all 48 release tags with the module in the history clone show the same three facts (put before addDocument, commit and refresh; search pairs the searcher with the live map; position-based cache ids); 8.8.2-8.11.3 are not in the history clone and are inferred between 8.8.1 (same code) and the executed 8.11.4. Not fixed upstream: the last monitor commit on upstream main is df6f71a3729 (2026-09-25, formatting only), branch_10x and branch_10_5 carry no fix, and no open PR changes commitWithoutNotify or search (2026-10-27) [read]. Not examined: the read-only Monitor (ReadonlyQueryIndex, 9.2.0+), which has no register path. 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
semi-formal; also reached by code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new, confidence medium-high): 288 recorded queries, namely 48 GitHub issue and PR queries through the scripted prior-report search, 37 further GitHub searches (apache/lucene issues and PRs including migrated LUCENE issues and the full module:monitor label, flaxsearch/luwak issues, PRs and sources, apache/solr, OpenSearch, Elasticsearch, a global issue and code search for WritableQueryIndex and commitWithoutNotify, which found only apache/lucene and verbatim copies), 129 mailing-list queries (dev@, java-user@, issues@, commits@, builds@), 12 JIRA, 9 commit, 6 downstream and 37 web queries, plus 10 groups of direct reads, and the changed files of all 408 open apache/lucene PRs. Closest items, none of them this bug: apache/lucene#679 review comment (2022-02-23/28: GitHub romseygeek flagged the reverse-order race, searcher refreshed before the cache map is updated, in a draft read-only index, resolved it by removing that cache, and said the writable in-memory cache 'works'); flaxsearch/luwak#68 (2015, the predecessor project: two other update-versus-match windows, both fixed, and the fixes are still in Lucene); #14886 and its fix #14885 (2025: the same missed-match symptom from position-based cache ids, caused by decomposition order across JVMs, fixed without touching this path; the repro fails on main after #14885); #12801 (latency of match during concurrent register and purge, whose demo re-registers identical content); #10830 (LUCENE-9791, missed matches from BytesRefHash#find's shared scratch buffer); luwak#40, luwak#74, luwak#174 and apache/solr#2382 (design remarks that assume the cache and the index stay in sync). Not fixed upstream (see affected_versions). Gaps: keyword search only, and GitHub's search rate limit was reached (no query failed); for the generic list queries only the subjects were read; a mention buried in an unrelated thread, in a private downstream tracker or in a private report to security@lucene.apache.org cannot be ruled out.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) with gradle in a scratch worktree and with the standalone JUnit runner, assertions on and off; the 10.5.2 release jars (this doc's test). The hunt's JUnit, the independent checks, the registry re-runs and the audit ran on main a9779ef3c193; the controlled-concurrency hunt on ea57360912ed, whose monitor module is identical; the follow-up's version-portable check 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, 10.2.2, 10.3.0 and 10.5.2. Both fix variants were validated in the scratch worktree against the new test, the hunt's JUnit, a hook-free stress test, the closest suites with nightly tests (29 tests) and the whole monitor module (184 tests), plus format, ecjLint and forbiddenApis; the exact runs differ per variant (Suggested fix). An independent fact check re-ran the gradle command and the final test file on main and 10.5.2 (assertions on and off), an on-disk variant, and both fixes' module runs.

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:107-133 (at a9779ef3c193) (commitWithoutNotify under commitLock and the purge read lock: this.queries.put(cacheId, entry) at :121 before writer.addDocument at :122, writer.commit at :127 and manager.maybeRefresh at :128), :206-219 (search: manager.acquire() and queries = this.queries taken together under the purge read lock at :211-217), :280-290 (implementation note of #12801: 'commit is slow and search cannot be slow'), :384 (MonitorQueryCollector: queries.get(cache_id))
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60 (at a9779ef3c193) (decompose: cacheId = query id + '_' + position, :56); QueryDecomposer.java (clause-ordered decomposition since #14885)
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:38-41 (at a9779ef3c193) (class javadoc, no thread-safety statement), :131 (purgeCache 'normally called from a background thread'), :147-176 (register javadoc and batched commits by commitBatchSize), :296 (StandardQueryCollector skips a missing cache entry); ConcurrentQueryLoader.java:38 ('before starting matches'); lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitor.java:71 (testUpdatesOverwriteOldQueries)
The finding in 30 seconds
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:79, (at a9779ef3c193) 107-133, 185-203, 206-219, 384; lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:154-184 (swapReference at :172, inside maybeRefresh)
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60; (at a9779ef3c193) QueryDecomposer.java:77-117 (LinkedHashSet at :80); Monitor.java:240-243, 294-303
Who is affected
  • lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:35, (at a9779ef3c193) 41; Monitor.java:56-57, 76-77, 152-162; QueryIndex.java:142-156; MonitorConfiguration.java:70-90 (setIndexPath, setDirectoryProvider)
Why it looks unintended
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:211-217, (at a9779ef3c193) 276-290; Monitor.java:38-41, 131, 296; ConcurrentQueryLoader.java:37-38
  • lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitor.java:71-86; (at a9779ef3c193) lucene/CHANGES.txt:3186-3187 (GITHUB#12801), 5386-5388 (LUCENE-9791)
Suggested fix
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryIndex.java:142-156; (at a9779ef3c193) WritableQueryIndex.java:137-181; Monitor.java:296

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

Repro files

About the diagrams

A step-by-step picture made in parallel with this doc from an executed probe of the same scenarios on main a9779ef3c193 (the edit apple OR banana to cherry OR apple, the new leading clause and the position-stable control, with the registering thread parked at the start and at the end of the commit; stock and with fix variant A).

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.