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.
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.- You register
q = apple OR banana. The Monitor keeps each clause twice: as a document in its query index (q_0indexed underapple,q_1underbanana) and in an in-memory cache under the same ids (q_0 = apple,q_1 = banana). - Another thread edits
q:register(q = cherry OR apple). The ids come from clause positions, so nowq_0 = cherry,q_1 = apple.registeroverwrites the cache first; the new index documents become searchable only at the end, after its commit and refresh. - Meanwhile you match a document
apple. You expectq: the old and the new version both match it, and every order of the two calls reportsq. - Instead the match returns nothing. The old index still finds
q_0(indexed underapple), the cache now saysq_0ischerry, which does not match, and the newq_1 = appleis not searchable yet. Onceregisterreturns, the same match reportsqagain. - No test hooks are needed: while one thread re-registers
q400 times back to back, 24,883 of 26,128 concurrent matches (95%) missq; an edit that keepsappleatq_0misses 0 of 11,067.
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.
- Monitor: the
lucene/monitormodule 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 ofqusesq_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.
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) andmanager.maybeRefresh()(:128) come last. The searcher is an NRTSearcherManagerover the writer (:79): only the refresh makes the deletes and new documents visible.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 withqueries.get(cache_id)(:384), andStandardQueryCollectorruns that clause (Monitor.java:294-303).- Between the first
putand the moment the refresh swaps in the new searcher, a match that starts gets the old searcher, which holds the oldq_0document (indexed underapple) while the map saysq_0 = f:cherry. The presearcher picksq_0for a documentapple, the matcher runsf:cherry, no match; the newq_1 = f:appleis 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 insideaddDocumentand insideDirectory.sync. A match that took the old searcher keeps it until it finishes, even ifregisterreturns meanwhile (by reading). - Matches that captured the map before
registerstarted and are still collecting can see the overwrite too (a first fix attempt that moved theputlater but still changed the live map kept failing). - 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
puttouches only the cache ids of the queries in the call (in a multi-idregister(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. - Moving the
putafter 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.matchwhile another thread callsMonitor.registerfor an existing id: a percolation or alerting service that keeps matching a document stream while users edit standing queries. Upstream #12801 treatsmatchrunning concurrently withregister/deleteByIdandpurgeCacheas 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 defaultTermFilteredPresearcher(Monitor.java:56-57,:76-77) and decomposer. An on-disk monitor is not the default (setIndexPathorsetDirectoryProvider) but shares the code path: the doc's test withsetIndexPathand a serializer fails 2 of 3 with assertions on and off, the control passes (fact check). The read-only Monitor has noregisterpath 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 (
appletocherry 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). Afterregisterreturns, the same match reports the query again. - Window size. One
registercall: anaddDocumentper 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 bulkregister(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).
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).
/*
* 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();
}
}
# 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.
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)search()takes the searcher and the cache together under a lock on purpose;registerbreaks 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:
qmatches before, after, and in every serial order.
The full argument385 words
- The pairing is deliberate.
search()takesmanager.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").registermutates 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-290arranges 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), notregister. - Replacing by id is the tested update path:
TestMonitor.testUpdatesOverwriteOldQueries(TestMonitor.java:71-86) and the delete-by-id loop incommitWithoutNotify. - No state gives this answer. Both versions match the document,
qmatches before and after, and every serial order reportsq; a stale-but-consistent answer would still report it. This assumesregistertakes 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 takingQueryobjects 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.
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.
+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
registercall (or per batch of about 5,000 in a bulk call). In a short probe (100,000 standing queries,qre-registered 200 times back to back, one matching thread, two runs on a loaded shared host):registermedian 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,commitormaybeRefreshthat 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
addDocumentandDirectory.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;registermedian 27-35 ms. That is the kind of search stall #12801 removed. - Same map copy per
registercall as B. - Failure path (by reading, not tested):
this.queries = nextis set beforemaybeRefresh(); 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
ea57360912edjars (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).
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.
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, a Monitor-based alerting or percolation service can silently miss an alert for a standing query while a user is editing it.
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-128versus: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
InfoStreamhook parksregisterat 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs, review comments and commits; flaxsearch/luwak; apache/solr, OpenSearch, Elasticsearch; global code search | 2026-10-27 | 48 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 PRs | no 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 web | 2026-10-27 | 12 JIRA, 129 mailing-list and 37 web queries; 10 groups of direct reads (LUCENE-8766 thread, Luwak sources) | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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 | affected | this 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 | affected | version-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 | affected | version-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 | affected | version-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 module | no monitor module (no lucene-monitor-8.1.1 artifact) |
| main + fix B or fix A | fixed | the 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) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (Fray + JUnit) | candidate | Fray 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 | real | Own 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, medium | Read 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 intended | Built 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, high | Re-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 | keep | Fresh 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 validated | New 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 fixes | Re-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 overclaims | OpenAI 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 443 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,739 words
- 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 maina9779ef3c193; the controlled-concurrency hunt onea57360912ed, 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.
- 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-38lucene/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
- TestMonitorMatchDuringReRegister.java (JUnit test):
repro/LFV-772/src/org/apache/lucene/monitor/TestMonitorMatchDuringReRegister.java(91 lines)
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).
assets/LFV-772/bug.png(443 KB)
