The Monitor indexes a query as one document per part (a OR b OR c gives 3). register adds them one by one under a commit lock; deleteById and clear send their delete to the index before taking that lock, so the delete can land between two adds.
register adds q's parts one by one under the commit lock; the delete lands after the first part, so the index keeps only the parts for b and c, and a document with a no longer matches. Select the still to open it at full size. Generated with AI, checked against the code.- Register
q = a OR b OR con a defaultMonitor. The Monitor stores q as three parts, one per clause, andregisterwrites them one after another while holding its commit lock. - While
registeris writing the second part, another thread callsmonitor.deleteById("q"). You expect the two calls to take effect in some order: q ends up complete (3 parts) or gone (0 parts). - Instead the delete does not wait for the lock. It removes the one part already written, and
registerthen writes and commits the other two:getQueryIds()is[q],getDisjunctCount()is 2, and a document with onlyagets 0 matches whilebandcstill match. - No error. The partial query survives close, reopen and
purgeCacheuntil q is registered again or deleted.clear()has the same gap for any multi-partregisterin flight; at the same point it also drops the part being written (deleteAllwaits for it), so only the third part survives. Without any test hook, 15-37 of 400 forceddeleteByIdraces and 20-53 of 400clearraces on a 64-clause query ended partial in the recorded runs.
Applications of the optional lucene-monitor module, default settings, that change queries from more than one thread: for example a user deleting a saved alert while an edit of it is applied, or a reload calling clear() while another path registers. Any query with two or more parts can be hit; single-threaded updaters are safe.
Mechanism, terms and numbers653 words
The Monitor indexes a registered query as one document per decomposed part, and register adds those documents one by one while holding commitLock. Monitor.deleteById and Monitor.clear send their IndexWriter delete before they take that lock, so a delete or clear that arrives while register is between two addDocument calls removes only the parts already added. The other parts are committed: the query is still reported as registered, but it silently misses every document that only the removed parts match, and for deleteById that was shown to survive close, reopen and cache purges until the id is registered again. Neither order of the two calls can produce this state. It has been in every release with lucene-monitor, from 8.2.0 to 10.6.0 (17 of them run, the rest by code identity, 10.6.0 by reading), and is on main.
- Monitor (
lucene-monitor): stores queries under ids and matches documents against them (reverse search). Internally it keeps the queries in a small Lucene index, the query index. - Decomposition: the default
QueryDecomposersplits a disjunction into its clauses. Each part becomes one query-index document with the query's id in the field_query_idand a cache idq_0,q_1, ... (QueryCacheEntry.java:51-60,WritableQueryIndex.java:185-203). Matching searches the query index, so a part without a live document can never match. - commitLock: the lock in
WritableQueryIndexthat serializes changes to the query index (WritableQueryIndex.java:64).
registercallscommitWithoutNotify(WritableQueryIndex.java:107-133). Insidesynchronized (commitLock)(:109) it deletes the id's old documents (:117-119), then callswriter.addDocumentonce per part (:120-125), then commits and refreshes (:127-128).deleteQueries(behindMonitor.deleteById) callswriter.deleteDocuments(new Term(FIELDS.query_id, id))(:339-341) beforecommitWithoutNotify(Collections.emptyList())takes the lock (:342).cleardoes the same withwriter.deleteAll()(:348).IndexWriterorders every change by a sequence number, and a term delete applies only to documents indexed before it (IndexWriter.java:117-119,DocumentsWriterPerThread.java:361-383). A delete that arrives whileq_1is being added deletesq_0and keepsq_1and every later part.registerthen commits the survivors.getQueryIds(an index scan,Monitor.java:276-280) still lists q,getDisjunctCount(numDocs,Monitor.java:257-262) counts the survivors, andmatch(WritableQueryIndex.java:205-246) can only reach parts that have a document.- On reopen the cache is rebuilt from the stored query, all three parts, but the index still has only
q_1andq_2(WritableQueryIndex.java:137-181);purgeCacherebuilds the cache from the index (WritableQueryIndex.java:249-258), so neither repairs it (both executed fordeleteById). Registering q again (delete all, add all) or deleting it does. clear()usesdeleteAll(), which is not tied to an id, so it can cut short theregisterof any multi-part query. ItsIndexWriterside differs:deleteAllwaits for the part being added and then drops every buffered document (IndexWriter.java:2765,DocumentsWriter.java:266-325). In the pinned timing onlyq_2survives:getDisjunctCount=1, onlycmatches. A single-part query cannot end partial: its one add is either before or after the delete.
- Pinned test (
q = a OR b OR c, the delete arrives while the second part is indexed):getQueryIds=[q],getDisjunctCount=2, a document withagets 0 matches. An independent reproducer's test of the same timing also showsbandcmatching once each, and a persistent Monitor the same before and after close and reopen, withgetQuery("q").getQueryString()=a b c(the query string it passed at register; without a serializergetQuerythrows). Withclear()in the same timing:getDisjunctCount=1, onlycmatches. - Hook-free stress test (64-clause disjunction, stock presearcher, random 0-3 ms delay before the delete, 400 races per method): 15-37 of 400
deleteByIdraces and 20-53 of 400clearraces ended partial in the recorded runs on main, 10.5.2, 9.12.3 and 8.2.0, for examplegetDisjunctCount=59of 64. These are forced-race rates, not field rates.
Exposure and likelihood173 words
- Who. Embedders of
lucene-monitor(alerting, saved searches, routing, classification, content screening) that callregisteranddeleteById(same id) orclear()(any id) from different threads. The documents passed tomatch()cannot trigger it. - Default or opt-in. Default within the module:
new Monitor(analyzer)with the defaultMonitorConfiguration,TermFilteredPresearcherandQueryDecomposer, in memory or persistent. The module itself is optional. - Window. The time
registerneeds to index the query's remaining parts, so it grows with the number of parts. A three-clause disjunction suffices. - Damage. The query is reported as registered but matches only a subset of what it should. No exception and no log in the executed runs. For
deleteByIdit survives commit, close, reopen andpurgeCacheuntil the id is registered again or deleted.deleteByIdleaves other queries alone,clear()removes them as it should, and nothing over-matches. - How likely. Rare: it takes concurrent mutation inside a short window, and no report of it was found since 8.2.0 (2019). An application that applies all query changes from one thread never hits it.
The test shows that Monitor.deleteById("q") running while register is indexing q = a OR b OR c (a hook pauses register while part f:b is indexed until deleteById is blocked) leaves q registered but only partly indexed.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.monitor;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Map;
import java.util.function.BiPredicate;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestMonitorDeleteByIdDuringRegister extends LuceneTestCase {
private Monitor monitor;
private Thread delete;
private void deleteWhileIndexing() {
if (delete == null) {
delete =
new Thread(
() -> {
try {
monitor.deleteById("q");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
delete.start();
while (delete.getState() != Thread.State.BLOCKED && delete.isAlive()) {
Thread.yield();
}
}
}
public void testDeleteByIdDuringRegisterLeavesQueryPartlyIndexed() throws Exception {
Query b = new TermQuery(new Term("f", "b"));
Presearcher presearcher =
new Presearcher() {
final Presearcher in = new TermFilteredPresearcher();
@Override
public Query buildQuery(LeafReader reader, BiPredicate<String, BytesRef> termAcceptor) {
return in.buildQuery(reader, termAcceptor);
}
@Override
public Document indexQuery(Query query, Map<String, String> metadata) {
Document doc = in.indexQuery(query, metadata);
if (query.equals(b)) {
TokenStream hook =
new TokenStream() {
@Override
public boolean incrementToken() {
deleteWhileIndexing();
return false;
}
};
doc.add(new Field("hook", hook, TextField.TYPE_NOT_STORED));
}
return doc;
}
};
Query q =
new BooleanQuery.Builder()
.add(new TermQuery(new Term("f", "a")), Occur.SHOULD)
.add(b, Occur.SHOULD)
.add(new TermQuery(new Term("f", "c")), Occur.SHOULD)
.build();
Document docA = new Document();
docA.add(new TextField("f", "a", Field.Store.NO));
try (Monitor m = new Monitor(new StandardAnalyzer(), presearcher)) {
monitor = m;
monitor.register(new MonitorQuery("q", q));
delete.join();
// expected: q matches docA iff q is still registered; actual: registered q misses docA
assertEquals(
"matches of docA, getQueryIds=" + monitor.getQueryIds(),
monitor.getQueryCount(),
monitor.match(docA, QueryMatch.SIMPLE_MATCHER).getMatchCount());
}
}
}
# in a copy of this finding's repro folder (repro/LFV-501/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/monitor/build/libs/lucene-monitor-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.monitor.TestMonitorDeleteByIdDuringRegister
- Expected vs actual
- Expected a document with only 'a' to match q exactly when q is still registered (1 match, or q gone). Actual: getQueryIds=[q] but 0 matches (expected:<1> but was:<0>).
More files, environment, full output37 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: matches of docA, getQueryIds=[q] expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.monitor.TestMonitorDeleteByIdDuringRegister.testDeleteByIdDuringRegisterLeavesQueryPartlyIndexed(TestMonitorDeleteByIdDuringRegister.java:101)- No order of the two calls gives this state: q is listed as registered, yet misses some of its own matches.
- Every other query-index document change takes the commit lock, and both methods take it right after their delete.
- The module's author fixed the same kind of race in
update()in the predecessor project (2015).
The full argument319 words
- No explicit promise. The
Monitorclass javadoc and theregister,deleteByIdandclearjavadocs say nothing about thread safety or atomicity (Monitor.java:38-41,:147-205). The contract is implied by the design, not documented. - A query is stored as all its parts.
getDisjunctCountis "the number of queries (after decomposition) stored in this Monitor" (Monitor.java:257-259), andregisterreplaces all parts of an id under one lock, so tworegistercalls on the same id are already atomic (WritableQueryIndex.java:109-125). - One lock for every document change.
commitLock(WritableQueryIndex.java:64) guardscommitWithoutNotify(:109) and both cache swaps inpurgeCache(:294,:305). The two deletes are the only document changes outside it (close()also commits outside it,:321-325).deleteQueriesandclearwait for it right after their delete (:342,:349), so the placement buys almost no concurrency. - Concurrent use is designed in, at least in part. A background purge thread runs by default (
WritableQueryIndex.java:86-98; "This is normally called from a background thread",Monitor.java:131), andConcurrentQueryLoaderis a "Utility class for concurrently loading queries into a Monitor" (ConcurrentQueryLoader.java:35), documented for loading "before starting matches" (:37-38). These show purge-versus-change and register-versus-register concurrency, not register racing delete or clear. Upstream fixed earlier Monitor concurrency bugs (LUCENE-9791 / #10830; #12801, whose description hasMonitor.match"called concurrently with Monitor.register/Monitor.deleteById and QueryIndex.purgeCache"). - Precedent. In Luwak, the predecessor project, flaxsearch/luwak#68 (2015, another contributor's report) showed
update()issuing its delete outside the synchronized commit; the maintainer answered "I think you've identified genuine issues" and commit 18fc644 "Move queryindex deletions into synchronized block" fixedupdate()only.deleteByIdandclearkept their unlocked deletes and were imported into Lucene in 2019. - Tests. No upstream test runs
deleteByIdorclearconcurrently withregister: inTestCachePurging.doConcurrentPurgesAndUpdatesTestthe deletes (TestCachePurging.java:108-110) finish before the second thread starts (:114).
The devil's-advocate check211 words
The strongest "works as designed" case (independent reviewers, a devil's advocate and a cross-model fact check): Monitor never says it is thread-safe, and Java classes with mutable state are not normally presumed thread-safe unless documented; ConcurrentQueryLoader documents only concurrent loading; register(Iterable) already commits big calls in chunks; IndexWriter's add, delete, add order is legal; an application that races register(q) against deleteById(q) cannot know whether q survives anyway; and clear(), a global reset, is something callers may be expected to run while no other writes are in flight. The cross-model reviewer rated this case moderate overall, somewhat stronger for clear().
Why it fails: either serial outcome would be fine, but this is a third outcome, a query reported as registered that silently matches only some of its clauses, persistently. The chunking is per query, so all parts of one query are always written in one locked call: the single query is exactly the unit the design protects. clear() hits a register of any id, so "the application races on the same id" does not hold there; the "quiesce before a reset" expectation is undocumented, but it is the strongest point against the clear() half. What survives is a severity point (a rare trigger), not a validity point. Verdict: a defect.
Recommended Take commitLock around the delete and the commit in deleteQueries and clear
Take commitLock in deleteQueries and clear around the IndexWriter delete and the commit (commitWithoutNotify re-enters it). Validated: the pinned test and the stress test pass (0 of 400 partial), and the whole monitor module passes with assertions on and off.
public void deleteQueries(List<String> ids) throws IOException {
synchronized (commitLock) {
for (String id : ids) {
writer.deleteDocuments(new Term(FIELDS.query_id, id));
}
commitWithoutNotify(Collections.emptyList());
}
listeners.forEach(l -> l.afterDelete(ids));
}
// clear(): the same, with writer.deleteAll() inside synchronized (commitLock)All options and trade-offs4 options · 557 words
Option A was built and validated in a scratch worktree at ea57360912ed; the others are untested alternatives.
Take commitLock around the delete and the commit in deleteQueries and clear (validated by the checks) (recommended)
+11/-5 in WritableQueryIndex.java. Java monitors are reentrant, so the nested commitWithoutNotify call takes the same lock again; the listeners still run after the lock is released. With the fix the race resolves to a serial order: in the pinned test, register then delete (q gone).
public void deleteQueries(List<String> ids) throws IOException {
synchronized (commitLock) {
for (String id : ids) {
writer.deleteDocuments(new Term(FIELDS.query_id, id));
}
commitWithoutNotify(Collections.emptyList());
}
listeners.forEach(l -> l.afterDelete(ids));
}
// clear(): the same, with writer.deleteAll() inside synchronized (commitLock)Trade-offs
- Little concurrency is lost: both methods already wait for
commitLockright after the delete; only the delete call itself moves inside, so a concurrentregisteror purge swap now waits for it. A term delete is cheap;deleteAll()waits for in-flight indexing, aborts buffered documents and merges and waits for pending flushes (IndexWriter.java:2750-2815). - The Monitor-level lock order stays
commitLockthenpurgeLock, as incommitWithoutNotifyandpurgeCache.match()takes onlypurgeLock's read lock briefly (WritableQueryIndex.java:211-217), so searches do not wait. TheIndexWriterlocksdeleteAll()now takes undercommitLockwere not analysed beyond the module's tests. - It does not serialize
close(), which still closes and commits theIndexWriteroutsidecommitLock(WritableQueryIndex.java:321-325). - Does not touch the other Monitor bugs (a duplicate id in one
registercall, a failedregisternot rolled back), which have other fix sites.
What the checks validated
- Stock: the pinned test fails with assertions on and off; the stress test fails 2 of 2 (30/400, 42/400).
- Fixed: the pinned test passes with assertions on and off and with 20 iterations; the stress test passes with assertions on and off (0/400 and 0/400, printed with
-Ptests.verbose=truein the fact check's re-run);TestCachePurging,TestMonitor,TestMonitorPersistenceandTestConcurrentQueryLoaderpass with 5 iterations; the whole:lucene:monitor:testpasses with assertions on and off (34 classes, 184 tests, 1 skipped, 0 failures);:lucene:monitor:check -x testpasses. - Earlier checks: the same fix shadowed into the stock jars passes the pinned test with fresh seeds and the stress test (0 of 400), and all upstream monitor tests pass with it.
Give commitWithoutNotify the ids to delete and a "delete all" flag, so that all IndexWriter writes of the Monitor happen in one place under commitLock.
Trade-offs
Same behaviour as the recommended option and harder to break later, but a bigger diff.
In commitWithoutNotify, replace "delete the id, then one addDocument per part" with one writer.updateDocuments(new Term(FIELDS.query_id, id), docsOfId) per id, which "Atomically deletes documents matching the provided delTerm and adds a block of documents" (IndexWriter.java:1594-1596).
Trade-offs
By reading DocumentsWriterPerThread.java:265-301, 361-383, a concurrent term delete then hits all of the block or none of it, which would fix deleteById; but clear()'s deleteAll() would still run outside the lock, it changes how the query index is written, and it was not run.
Javadoc only.
Trade-offs
Leaves the silent partial state for anyone who misses the note, while the fix is a few lines.
TLA+ model checking. While modelling the Monitor's write protocol (decomposition, concurrent deletes and clears, restart), an AI tool found that the invariant "each query is indexed as none or all of its parts" fails when a delete or clear runs during register, and reduced the trace to a JUnit test on the stock jars.
The method in full192 words
Method: TLA+ model checking (TLC, a tla2tools development build of 2026-10-20, rev d2e1472), a hunt unit (Monitor lifecycle and persistence, 2026-10-26).
- The finder modelled
WritableQueryIndex's write protocol with query decomposition, concurrentdeleteByIdandclear, non-aborting add failures and close plus reopen, with a model-to-Java line map. The invariant AtomicQuery (in every idle state each id's indexed documents are none or all parts of one version) is violated forregisterconcurrent withdeleteById(counterexample after 359 distinct states) and withclear(after 454). A fixed variant holds (13,440 distinct states); it models a combined fix (the deletes undercommitLockplus one atomicupdateDocumentsper query and cache puts after the add), not the recommended fix alone, which the Java tests validated. - Caveat: the model was written while reading the code, and the kept TLC logs are dated after the candidate was first recorded, so the hypothesis may have come from the code reading ("semi-formal" in the hunt's terms).
- The same unit's exhaustive single-threaded model-based tests (every script of up to 4 operations checked after every step, every 5-operation script at its final state) found 0 mismatches: the bug needs two threads.
No prior report found (2026-10-26, 221 recorded searches over GitHub, the mailing lists, JIRA, the predecessor project's tracker and the web). Closest: a 2015 report of the same race class in the predecessor project's update(), fixed there for that method only.
Searches4 searches
Not fixed upstream either: upstream main, branch_10x, branch_10_6, branch_10_5 and branch_10_4 still delete before taking commitLock (read-only GitHub contents API), and the two open PRs that touch WritableQueryIndex.java (#13948, #16847) do not change deleteQueries or clear. The predecessor-project report (flaxsearch/luwak#68, fixed for update() by 18fc644) is a precedent, not a report of this bug: it never names deleteById or clear, whose unlocked deletes the fix left in place. Gaps: keyword search only; GitHub search does not index PR diffs (covered for open PRs by a changed-file listing); private security reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, flaxsearch/luwak, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, all of GitHub) | 2026-10-26 | 32 prior-report-tool queries and 57 GraphQL searches (class, method, field and lock names; race, thread-safe, concurrent, partial, disjunct phrasings; label module:monitor) | only related items: #12801, #10830 (LUCENE-9791), #12318, #679, luwak#174 |
| Luwak tracker and open Lucene PRs | 2026-10-26 | all 191 Luwak issues and PRs listed; the changed files of all 408 open apache/lucene PRs; all 58 comments of #679 | luwak#68 (2015, update() only, fixed by 18fc644); #13948 and #16847 touch the file but not deleteQueries or clear |
| Mailing lists (dev@, java-user@, issues@, commits@) and JIRA | 2026-10-26 | 40 tool queries and 30 lists.apache.org queries; 8 tool and 7 JQL queries (component modules/monitor, luwak, deleteById, thread safe, race, clear) | nothing about this bug |
| Commits, downstream code and the web | 2026-10-26 | 6 commit queries, 4 downstream-repository queries, 12 web queries from the two tool runs, 18 more web queries (one throttled) and 7 direct GitHub and history lookups | nothing about this bug |
Present since the module was added to Lucene (LUCENE-8766, 2019, first released in 8.2.0) and unchanged since; the pinned test fails on every release tested from 8.2.0 to 10.5.2 and on main. Not fixed upstream.
Versions tested8 rows
Introduced by 251dbe7cea8 "LUCENE-8766: Add monitor subproject" (master, 2019-06-13; JIRA era, no associated GitHub PR found), shipped first through the same-day branch_8x backport b8773d13ad8 in 8.2.0. The code came from Luwak with the unlocked deletes already in place, so it is not a regression. #679 (9.2.0, through the branch_9x backport 72cf36a2b8d) moved the delete-then-commit sequence into WritableQueryIndex unchanged, together with the listener calls it moved there from Monitor; #12801 (9.9.0) changed only purgeCache; #14885 (10.3.0) made the decomposition order stable, which changes which parts survive in the pinned test (only b before, b and c after), not the bug. The lock placement is identical in all 49 release tags with the module in the history clone (8.2.0-8.8.1, 9.0.0-10.5.2) and on main; with the listener lines the bodies have two variants (8.2.0-9.1.0, 9.2.0-main). 8.8.2-8.11.3 are counted as affected by inference (8.8.1 source, 8.11.4 executed).
| Version or commit | Verdict | Result |
|---|---|---|
| 8.1.1 | not affected | no monitor module (no lucene-monitor artifact) |
| 8.2.0 | first affected | pinned test fails 10 of 10 iterations (getDisjunctCount=1, misses a); stress 20/400 and 28/400 |
| 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.8.0, 9.9.0, 10.0.0 | affected | pinned test fails (getDisjunctCount=1, misses a) |
| 9.12.3 | affected | pinned test fails (getDisjunctCount=1); stress 16/400 and 28/400 |
| 10.1.0, 10.2.0, 10.2.2, 10.3.0, 10.3.2, 10.4.0, 10.5.0 | affected | a diagnostic variant of the pinned test prints a partial query: getDisjunctCount=1 (only b matches) on 10.1.0-10.2.2, getDisjunctCount=2 (b and c) from 10.3.0 |
| 10.5.2 | affected | pinned test fails (getDisjunctCount=2); stress 18/400 and 29/400, 20/400 and 30/400 |
main ea57360912ed | affected | pinned test fails with assertions on and off (20 of 20 iterations each, gradle and harness); stress 15-37/400 and 20-53/400 in the recorded runs |
| main + fix | not affected | both tests and the whole monitor module pass |
Every check found it real except one read-only reviewer, whose "not real" was procedural: it could not run Java and agreed with the root cause. An independent reproducer, a devil's advocate and an adjudication ran their own tests; fact checks and a diagram check of this doc followed, and their findings are fixed.
Checks and verdicts470 words
All checks ran on main ea57360912ed unless noted; the reviews from another model family read the code only.
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+ and pinned test) | candidate | AtomicQuery violated for deleteById and clear; the pinned test fails with assertions on (20 of 20 iterations) and off, on 10.5.2 and 9.12.3, and passes with the fix. |
| Refute review (OpenAI GPT-5.6, reading only) | not real (low, procedural) | Could not run Java, so it voted not real on procedure only; it agreed with the root cause and argued that Monitor never promises thread safety. A separate run re-ran the repro: fails. |
| Independent reproducer | real | Own pinned test (in-memory and persistent, also after reopen) and own hook-free stress test, written from the claim: partial queries for deleteById and clear on main and 10.5.2; its own lock fix gives 0 of 400. |
| 'intended' review (OpenAI GPT-5.6, reading only) | real (medium); intended case moderate | Found no warning, precondition or experimental marker; a separate stress test: 25 of 2000 uncoordinated races partial, 0 with the fix. |
| Devil's advocate | real (medium-high) | Built the strongest works-as-designed case and rejected it; re-ran the repro on main and 10.5.2: fails. |
| Adjudication | confirmed (high), silent wrong results | Re-ran the independent reproduction's pinned test (2 of 2 fail) and stress test (19/400 and 31/400 partial) with fresh seeds. |
| History, prior reports, security | since 8.2.0; new; security none | Pinned test on 10 releases plus main, stress test on three releases, 221 recorded prior-report searches, a security assessment. |
| Registry re-run and audit | keep | Fresh seeds: pinned test fails with assertions on and off; stress 15/400 and 27/400, then 22/400 and 53/400; 0 of 400 with the fix. |
| This doc | reproduced; fix validated | Both tests through gradle on stock main (fail) and with the fix (pass, plus the whole monitor module and check); the stress test also on 10.5.2 through the harness (20/400, 30/400). |
| Fact check of this doc (independent, executed) | bug holds; 4 precision errors fixed | Re-ran the gradle commands (also with a new seed), the stress test, tidy, the fix validation (whole module, -ea and -da) and the pinned test on 8.2.0, 9.12.3, 10.5.2 and main -da x20. New probes: clear() in the pinned timing leaves only q_2; purgeCache and reopen keep the partial state; re-register or delete repairs it. |
| Fact check of this doc (OpenAI GPT-5.6, reading only) | core bug real; wording fixed | Agreed with the root cause; 15 factual errors (run attribution, history normalization, validation scope, the security framing) and 16 overclaims, resolved; rated the intended-behaviour case moderate, stronger for clear(). |
| Diagram check | mechanism correct; 2 captions fixed | Every code row, value and the order of events matched; two captions (the release range, the stress range) were corrected and the visuals re-rendered. |
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 468 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,506 words
- Registry title
- Monitor.deleteById and Monitor.clear issue their IndexWriter delete outside the commit lock, so a delete or clear that runs while register is indexing a decomposed query leaves that query partly indexed: it stays registered but silently stops matching what its deleted parts match, also after a reopen
- Area
- monitor / query index updates (WritableQueryIndex.deleteQueries and clear versus commitWithoutNotify, reached through Monitor.deleteById, Monitor.clear and Monitor.register)
- Priority
- Low, rank 277: Needs a delete or clear that races a register of the same query in the optional monitor module, a narrow window.
- Impact class
- silent wrong results
- Impact note
- Applications of the optional lucene/monitor module, with the default Monitor, MonitorConfiguration, presearcher and decomposer, that call Monitor.deleteById(id) from one thread while another thread's Monitor.register is indexing a query with that id that decomposes into several parts (any disjunction), or call Monitor.clear() while any such register is in flight, can end up with that query only partly indexed and no error: getQueryIds, getQueryCount and getQuery still report it, but documents that only its deleted parts match are silently missed; the partial state is committed, survives close and reopen of a persistent Monitor [exec] and is not repaired by purgeCache (code reading), until the id is registered again or deleted; ordinary scale (a three-clause disjunction suffices), and in hook-free stress tests about 1-8% of the races ended partial, depending on how the two calls were timed.
- Summary
- One thread registers query q = a OR b OR c, which the Monitor indexes as three parts, one per clause, while another thread calls monitor.deleteById("q") or monitor.clear(); you expect the two calls to take effect in some order, so that afterwards q is either fully registered or gone. Instead the delete goes to the IndexWriter without the commit lock that register holds, lands between register's per-part addDocument calls and removes only the parts already added. q is still reported as registered with its full query, getDisjunctCount is 2, and a document that contains only 'a' no longer matches, also after the Monitor is closed and reopened.
- Introduced
- 251dbe7cea8126cb7ecadc921ba444c63d06c618 (master, 'LUCENE-8766: Add monitor subproject', by GitHub romseygeek; JIRA LUCENE-8766, no GitHub PR, pre-GitHub-PR era; first released in 9.0.0), shipped first through the same-day branch_8x backport b8773d13ad8e249a045862249bf476c30a444461 (8.2.0) [read: git blame on main stops at be991789564 (#679, LUCENE-10422, 2022-03-21), which only moved deleteQueries, clear and commitWithoutNotify into WritableQueryIndex; blaming QueryIndex.java at be991789564~1 lands on the import; a whitespace- and rename-normalized hash of the deleteQueries and clear bodies is identical in every tag 8.2.0-8.8.1 and 9.0.0-10.5.2 and on main]. The unlocked deletes came with the code donated from the predecessor project Luwak, whose commit 18fc644 (2015, luwak#68) had moved only update()'s delete into the synchronized commit. Not a regression (2019-06-13 (both commits))
- First affected version
- 8.2.0 [exec] (first release with the monitor module; 9.0.0 is the first 9.x release); there is no unaffected earlier release: 8.1.1 has no monitor module (no lucene-monitor-8.1.1 artifact on Maven Central)
- Affected versions
- Every release that ships lucene-monitor, 8.2.0 to 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] the finder's pinned JUnit (deleteById, seed DEADBEEF, -ea) fails on 8.2.0 (10 of 10 iterations), 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.8.0, 9.9.0, 9.12.3, 10.0.0, 10.5.2 and main (8.x and 9.0.0 ran a copy that differs only in the LuceneTestCase import), main also with -da (20 of 20 iterations) and with the registration check's fresh seeds; the independent reproduction's hook-free stress JUnit (64-part disjunction, -da, 400 races per method) leaves partial queries for both deleteById and clear on 8.2.0 (20 and 28 of 400), 9.12.3 (16 and 28), 10.5.2 (18 and 29; the independent reproduction's own run 15 and 24) and main (the independent reproduction 25 and 23, adjudication 19 and 31, registration check 15 and 27). [read] the other releases by code identity, and 8.8.2-8.11.3 by inference between 8.8.1 (same source) and the executed 8.11.4 (the 8.x branch is not in the history clone). Before 10.3.0 the decomposition order depends on the hash seed (#14885 made it stable), which changes which parts survive in the pinned repro (b only instead of b and c) and could let the pinned repro pass on those releases with another seed; the order-independent stress test fails there. clear() was executed only through the stress tests. Not fixed upstream: upstream main and branch_10_4 still issue the deletes before taking commitLock, and no open PR changes deleteQueries or clear (2026-10-26) [read]. Not executed: x86, and each release's own JDK (all runs used JDK 25.0.3)
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- formal: TLA+/TLC
- Upstream and prior reports
- none found (searched 2026-10-26, read-only; the prior-report record, verdict new; the finder's scripted prior-report search re-read, the follow-up's searches): 221 recorded queries, namely two scripted prior-report search runs (32 GitHub issue and PR queries, 6 commit, 4 downstream, 40 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA-archive), 57 GitHub GraphQL searches over apache/lucene, flaxsearch/luwak, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub, 30 lists.apache.org queries, 7 JQL queries and 17 web queries, plus full listings of all 191 Luwak issues and PRs and of the changed files of all 408 open apache/lucene PRs and all 58 comments of #679. Not fixed upstream: upstream main and branch_10_4 still issue the deletes before taking commitLock (WritableQueryIndex.java last changed 2026-06-09 by #16197, behaviour-neutral), and the two open PRs that touch the file (#13948: populateQueryCache, #16847: numDocs) do not change deleteQueries or clear. Closest prior item, a precedent and not a report of this bug: flaxsearch/luwak#68 (2015, the predecessor project): update()'s delete issued outside the synchronized commit, fixed for update() only by luwak 18fc644. Related context: #12801, #10830 (LUCENE-9791), luwak#174, #12318. Gaps: keyword search only; GitHub search does not index PR diffs (covered for open PRs by the file listing); GitHub's search rate limit was saturated (covered through GraphQL); one web query was throttled; private reports to security@lucene.apache.org are not visible; 'not found' is a search result, not proof.
- Lucene commit tested
- main
ea57360912ed(commit of 2026-10-24; tested 2026-10-26): the pinned test and the hook-free stress test through gradle in a scratch worktree (assertions on and off, seed DEADBEEF) and through the standalone harness (fresh seeds); the fix in the same worktree (both tests, the closest suites with 5 iterations, the whole monitor module with assertions on and off,:lucene:monitor:check -x test), removed afterwards. Release jars: the pinned test on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.8.0, 9.9.0, 9.12.3, 10.0.0 and 10.5.2, a diagnostic variant on 10.1.0, 10.2.0, 10.2.2, 10.3.0, 10.3.2, 10.4.0 and 10.5.0, the stress test on 8.2.0, 9.12.3 and 10.5.2. An independent fact check repeated the gradle runs and the fix validation, ran the pinned test 20 times with assertions off and ran three probes: the pinned timing withclear(),purgeCacheand reopen after the race, and re-register or delete afterwards. All runs used OpenJDK 25.0.3 on Linux aarch64.
- Finding record
lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:64 (at ea57360912ed) (commitLock), :107-133 (commitWithoutNotify: under commitLock, deleteDocuments(Term(query_id, id)) at :117-119, then queries.put and one addDocument per decomposed part at :120-125, commit and maybeRefresh at :127-128), :338-344 (deleteQueries: writer.deleteDocuments before commitWithoutNotify), :347-351 (clear: writer.deleteAll before commitWithoutNotify)lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:38-41 (at ea57360912ed) (class javadoc, no thread-safety statement), :128-136 (purgeCache 'normally called from a background thread'), :152-204 (register, deleteById, clear javadocs, no atomicity statement), :257-279 (getDisjunctCount = numDocs, getQueryCount, getQueryIds read the index); lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60 (decompose: one entry and one index document per part, cacheId id_N); lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:35-52 ('Utility class for concurrently loading queries into a Monitor')
- The finding in 30 seconds
lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:64 (commitLock), :107-133 (commitWithoutNotify), :185-203 (buildIndexables), :205-246 (search), :249-258 and :268-315 (purgeCache), :137-181 (populateQueryCache), :338-344 (deleteQueries), :347-351 (clear)lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60 (decompose: one entry per part, cache id id_N)lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:152-162 (register), :184-205 (deleteById, clear), :257-280 (getDisjunctCount, getQueryCount, getQueryIds)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-119 (sequence numbers); DocumentsWriterPerThread.java:361-383 (finishDocuments applies concurrent deletes up to docIdUpTo)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2750-2815 (deleteAll; lockAndAbortAll at :2765); DocumentsWriter.java:266-325 (lockAndAbortAll; filterAndLock at :297, waitForFlush at :308)lucene/monitor/src/java/org/apache/lucene/monitor/QueryIndex.java:62-66 (getQuery throws without a serializer)
- Who is affected
lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:56-58 (default TermFilteredPresearcher); lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:205-246 (match reads the index only)
- Why it looks unintended
lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:38-41, :131, :147-205, :257-259lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:64, :86-98, :109-125, :294, :305, :342, :349lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:35lucene/monitor/src/test/org/apache/lucene/monitor/TestCachePurging.java:81-127 (deletes :108-110 before startUpdating :114)lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:37-38 ("before starting matches"); WritableQueryIndex.java:321-325 (close)
- Suggested fix
lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:109-110, :211-217, :294-314 (lock order)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1594-1596; DocumentsWriterPerThread.java:265-301, :361-383lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2750-2815 (deleteAll); DocumentsWriter.java:266-325 (lockAndAbortAll); lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:321-325 (close)
- TestMonitorDeleteByIdDuringRegister.java (JUnit test):
repro/LFV-501/src/org/apache/lucene/monitor/TestMonitorDeleteByIdDuringRegister.java(107 lines)
The diagram (bug.png) is the timeline of the pinned test: the two threads, the commit lock and the query-index contents after each step. Its numbers come from the pinned test's executed output on ea57360912ed.
assets/LFV-501/bug.png(468 KB)
![Timeline of the repro on Apache Lucene main. Thread 1 runs register(q = a OR b OR c) and holds the commit lock while it adds three query-index documents, q_0 (a), q_1 (b) and q_2 (c). After q_0 is added, thread 2 runs deleteById("q"), whose delete does not take the lock and removes only q_0; thread 1 then adds q_1 and q_2 and commits. Expected: q complete (3 parts) or gone (0 parts). Actual: getQueryIds=[q], getDisjunctCount=2, a document with a gets 0 matches, b and c match; the same after close and reopen.](../assets/LFV-501/bug.png)