The Monitor (lucene/monitor) reports which stored queries a document matches. It splits each query into branches, one per OR clause, indexes one entry per branch and caches each branch as q_0, q_1, ... An update of q deletes q's entries and adds the new ones. With two versions in one call, the delete runs once and both versions are added.
- A Monitor stores queries under ids, and registering an id again replaces its query. You apply a batch of edits in one call, a query created and then edited:
register(mq("q", "apple OR banana"), mq("q", "cherry")). - You expect q to behave like its newer version, as two separate calls do: q matches only the document "cherry", and
getDisjunctCount()is 1. - Instead the Monitor deletes q once, then adds both versions: its query index keeps apple (
q_0) and banana (q_1) from version 1 and cherry (q_0) from version 2. The cache has one slot per name, so cherry overwrites apple inq_0and banana stays inq_1. - So q matches {banana, cherry}, a set neither version matches, and
getDisjunctCount()is 3. No error, with assertions on or off. - A persistent index gives three answers for the same call: live {banana, cherry}; after a reopen {apple, banana}, the old version; a read-only Monitor {apple, banana, cherry};
getQuery("q")says cherry. It lasts until q is registered again on its own or deleted.
Users of the optional monitor module who put two versions of one id in one register call, within 5,001 entries: a batch of edits, or ConcurrentQueryLoader draining a create and an edit. Default in-memory settings; two entries suffice. Wrong live matches need the old version to have an extra branch or another filter value; otherwise they appear after a restart.
Mechanism, terms and numbers662 words
Registering a MonitorQuery whose id is already registered replaces the old query. When one Monitor.register call contains two versions of the same id, for example a query created and then edited in one batch, WritableQueryIndex.commitWithoutNotify deletes the id once and then adds every branch of both versions. Both stay in the query index, while the in-memory cache keeps one entry per cache id id_N, so the query runs a mix of the two versions. With q = apple OR banana then q = cherry, q matches {banana, cherry}, which neither version matches, and getDisjunctCount() is 3. On a persistent index the live Monitor, the reopened Monitor ({apple, banana}, the old version) and a read-only Monitor ({apple, banana, cherry}) disagree, and getQuery returns cherry. It is silent, needs only new Monitor(analyzer) with its default configuration for the in-memory case (the persistent views add an index path and a serializer), is reached through Lucene's own ConcurrentQueryLoader, lasts until the id is registered again on its own or deleted, and is in every release that ships the monitor module from 8.2.0 to 10.5.2, and on main.
- Monitor (
lucene/monitor): stores queries under ids and reports which stored queries a document matches. It is backed by its own small Lucene index, the query index. - Branches (disjuncts):
QueryDecomposersplits a query into branches, one per clause of a disjunction. Each branch becomes one query-index document and one cache entry namedid + "_" + ordinal(QueryCacheEntry.java:51-60):apple OR bananagivesq_0= apple andq_1= banana.getDisjunctCount()counts these documents (Monitor.java:257-262). - Matching: the document's terms select candidate query-index documents (the presearcher); for each candidate the Monitor looks its cache name up in the in-memory cache and runs that branch (
WritableQueryIndex.java:380-386).
Monitor.registercommits its input in chunks of up toqueryUpdateBufferSize + 1= 5001 entries (Monitor.java:152-162,MonitorConfiguration.java:35).- For one chunk,
WritableQueryIndex.commitWithoutNotifycollects the distinct ids, deletes each once, then adds every branch of every update and puts it in the cache by its cache name (WritableQueryIndex.java:113-125). - With two versions of q, the index keeps all 3 branch documents. Version 2's
q_0(cherry) overwrites version 1'sq_0(apple) in the cache; version 1'sq_1(banana) stays. - Live: "apple" selects version 1's
q_0document, whose slot now runs cherry: no match; "banana" runsq_1= banana; "cherry" runs version 2'sq_0. Result {banana, cherry}; 3 disjuncts. - Reopen:
populateQueryCacherebuilds the cache from the first stored document of each id and skips the rest, as "a branch of a query that has already been reconstructed" (WritableQueryIndex.java:152-172). The first document the scan visits is version 1's doc 0 (in this fresh index, the order the documents were added; the code promises no "first update" rule), so every candidate runs version 1: {apple, banana}. - Read-only Monitor: each candidate document's own stored query is deserialized (
ReadonlyQueryIndex.java:185-196), so both versions run: {apple, banana, cherry}. getQuerykeeps the stored query of the last document collected (QueryIndex.java:62-72), here version 2's doc 2: cherry.
register(q = apple OR banana, q = cherry) | q matches |
|---|---|
| two separate calls (control) | {cherry} in every view; 1 disjunct in memory |
| one call, in memory | {banana, cherry}, 3 disjuncts |
| one call, persistent: the live Monitor | {banana, cherry} |
| the same index, reopened | {apple, banana} (version 1) |
| a read-only Monitor on it | {apple, banana, cherry} |
getQuery("q") | cherry |
With single-term versions (q = apple, then q = cherry) the live Monitor happens to be right ({cherry}), but after a reopen it runs apple only, and a read-only Monitor runs both. Main with -ea and -da, and 10.5.2; before 10.3.0 the recorded runs (8.2.0 to 10.2.2, JDK 25) give {apple, cherry} live: the branch order came from a HashSet and was not specified until #14885 (10.3.0) made it stable, so the surviving stale branch may differ on another JVM there.
Exposure and likelihood400 words
- Trigger: two versions of one query id in the same
registercall and the same internal chunk of 5001 entries (defaultqueryUpdateBufferSize5000). Natural sources: an update stream or change log applied in batches (eachregistercall commits and refreshes,WritableQueryIndex.java:127-128, so batching is the cheap way), orConcurrentQueryLoader, whose worker passes everything it drained to oneregistercall (ConcurrentQueryLoader.java:134-143): a one-thread loader fed a create and an edit reached the mixed state in 20 of 20 and 30 of 30 runs in independent checks (with several workers, both versions must be drained by the same worker into one call). - Hidden boundary: the same id 5001 entries apart is handled correctly (executed: q, 5000 other ids, q, so 5002 entries; 5001 queries, 5001 disjuncts), so whether a repeated id breaks depends on a chunk boundary the caller cannot see.
- Default vs opt-in: the in-memory case uses
new Monitor(analyzer), so the defaultMonitorConfigurationandTermFilteredPresearcher, assertions on or off. The persistent views need an index path and aMonitorQuerySerializer, the read-only view a read-only directory provider, and the metadata case aTermFilteredPresearcherbuilt with a filter field (the default one has none,TermFilteredPresearcher.java:77-79). The monitor module itself is optional and less used. - Scale: none; two entries suffice.
- Damage: silent wrong matches when the old version has a branch the new one lacks (a shrinking edit keeps matching what it removed) or a different filter-field value (the old value keeps selecting the query). When both versions have the same branches and metadata, the live Monitor happens to be right, and the damage shows as an inflated
getDisjunctCount(), the old version running after a restart of a persistent Monitor, and both versions running in a read-only Monitor.getQuerynames a version the reopened Monitor does not run. - Bounds: separate calls are correct;
deleteByIdremoves every stale entry; a later singleregisterrepairs the id;purgeCache()does not; other ids in the batch are unaffected; repeating the batch does not add entries. No structural index corruption (the index stays readable; it holds stale query documents), hang or effect onIndexSearcherresults was observed. - How likely: rare (a judgement): it needs edits with repeated ids batched into one call; the two public downstream users found register one query per call (read only), and nobody has reported it since 8.2.0.
The test shows that registering two versions of query id q in one Monitor.register call keeps parts of both versions, so q matches like neither of them.
/*
* 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.List;
import java.util.Set;
import java.util.TreeSet;
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.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;
public class TestRegisterDuplicateIdInOneCall extends LuceneTestCase {
public void testOneBatchBehavesLikeOneOfTheVersions() throws Exception {
try (Monitor monitor = new Monitor(new StandardAnalyzer())) {
Query appleOrBanana =
new BooleanQuery.Builder()
.add(new TermQuery(new Term("f", "apple")), Occur.SHOULD)
.add(new TermQuery(new Term("f", "banana")), Occur.SHOULD)
.build();
Query cherry = new TermQuery(new Term("f", "cherry"));
monitor.register(
List.of(new MonitorQuery("q", appleOrBanana), new MonitorQuery("q", cherry)));
Set<String> matched = new TreeSet<>();
for (String text : List.of("apple", "banana", "cherry")) {
Document doc = new Document();
doc.add(new TextField("f", text, Field.Store.NO));
if (monitor.match(doc, QueryMatch.SIMPLE_MATCHER).matches("q") != null) {
matched.add(text);
}
}
// expected one version, [apple, banana] or [cherry]; actual [banana, cherry]
assertTrue(
"q matches " + matched + ", expected [apple, banana] or [cherry]",
matched.equals(Set.of("apple", "banana")) || matched.equals(Set.of("cherry")));
}
}
}
# in a copy of this finding's repro folder (repro/LFV-332/, 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.TestRegisterDuplicateIdInOneCall
- Expected vs actual
- Expected
qto match like one version,[apple, banana]or[cherry]; actual[banana, cherry], with-eaand-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 matches [banana, cherry], expected [apple, banana] or [cherry]
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.apache.lucene.monitor.TestRegisterDuplicateIdInOneCall.testOneBatchBehavesLikeOneOfTheVersions(TestRegisterDuplicateIdInOneCall.java:54)Monitorholds "a set of Query objects with associated IDs", and re-registering an id replaces its query (TestMonitor#testUpdatesOverwriteOldQueries).- Nothing says ids must be unique within one call, and Lucene's own
ConcurrentQueryLoaderpasses its queue to oneregistercall. - The result is no version at all ({banana, cherry}), and one persistent index gives three answers, whichever entry should win.
The full argument374 words
- Contracts.
Monitorclass javadoc: "A Monitor contains a set of Query objects with associated IDs" (Monitor.java:38-41);getQuery: "@return the MonitorQuery stored for this id, or null if not found" (Monitor.java:245-255);register: "Add new queries to the monitor" (Monitor.java:147-151,:168-173). No uniqueness precondition inregister,MonitorQuery(MonitorQuery.java:34-49), the package doc, CHANGES or MIGRATE. - The chunk size is a buffer setting:
setQueryUpdateBufferSizesets "how many queries will be buffered in memory before being committed to the queryindex" (MonitorConfiguration.java:150-160), yet it decides whether a repeated id breaks. - Tests.
TestMonitor#testUpdatesOverwriteOldQueries(TestMonitor.java:71-86) registersquery1as "this", then as "that" in a second call, and asserts that it matches a "that" document with one query run (it does not by itself show the old entry is gone, since "that" would not select it anyway);TestMonitorPersistence#testCacheIsRepopulated(TestMonitorPersistence.java:54-87) asserts that a reopened Monitor reports the same number of matches (4) and the same query count. No upstream test registers one id twice in one call. - The code assumes it. The commit deletes the batch's ids before adding, so a commit is meant to replace each id's earlier documents (this supports the defect, not last-wins in particular);
populateQueryCacheassumes all documents of an id are branches of one query. - History. The loop came with the module (LUCENE-8766, 2019) and moved unchanged in LUCENE-10422 (#679); neither discussion mentions repeated ids. Design context only, not a warning about this bug: in luwak#21 (2014) the author of the predecessor project Luwak explained why it keyed its query cache by the query string, not the id: "Just keying on the ID could result in weird results if a MonitorQuery is updated while a search is in progress - the presearcher could select a query based on it's current index, but then end up fetching an entirely different query from the updated QueryCache. Keying by the query string prevents this". That concern is an update racing a search. Luwak's
QueryIndex.commit(the follow-up's copy) has the same delete-then-add-all loop with a hash-keyed cache, so by code reading it would also keep both versions' documents; only the slot overwrite comes from the Lucene module'sid_ordinalkey, used since its first commit.
The devil's-advocate check157 words
The strongest case for "intended or misuse" (built by the devil's-advocate verifier; the GPT-5.6 reviews rated it moderate): register only says "Add new queries", replace-by-id is tested only across calls, so precedence inside one call is unspecified and callers should deduplicate; ConcurrentQueryLoader is documented as a startup loader ("useful to speed up startup times").
Why it fails: the precedence-free failure needs no rule about which entry wins, since {banana, cherry} is neither version and one persistent index gives three answers while getQuery names a fourth state; the commit loop deletes each id's earlier documents before adding; nothing rejects the input or documents a precondition; the library's own loader produces it (30 of 30 runs with one worker); and the outcome depends on an internal chunk boundary. At most it changes the fix (reject or last-wins), not whether today's state is a defect; last-wins is a proposal that matches consecutive calls, not an existing contract. Verdict: a defect.
Recommended Validate every entry, then keep the last update per id
In WritableQueryIndex.buildIndexables, validate every entry, then keep only the last update of each id (+8/-1), so a later entry replaces an earlier one exactly as across calls. The repro, the whole monitor module (185 tests, assertions on and off) and the static checks pass. Rejecting repeated ids instead makes ConcurrentQueryLoader silently drop the batch.
private List<Indexable> buildIndexables(List<MonitorQuery> updates) {
// commitWithoutNotify deletes each id once and then adds every entry, so keep only the last
// update of each id: a later update replaces an earlier one, as it does across commits
Map<String, MonitorQuery> lastUpdates = new LinkedHashMap<>();
for (MonitorQuery mq : updates) {
if (serializer != null && mq.getQueryString() == null) {
throw new IllegalArgumentException(
"Cannot add a MonitorQuery with a null string representation to a non-ephemeral Monitor");
}
lastUpdates.put(mq.getId(), mq);
}
List<Indexable> indexables = new ArrayList<>();
for (MonitorQuery mq : lastUpdates.values()) {
// ... unchanged: serialize, decompose, one document per branchAll options and trade-offs3 options · 740 words
One fix was built and validated in a scratch worktree at ea57360912ed; the reject alternative was run against the module's tests and a probe. Workaround in every affected release: deduplicate the batch before register (keep the last update per id) or register later edits in their own call; to repair an affected index, register each affected id again in its own call, or delete it.
Gives one call the meaning of consecutive calls, which the existing delete-then-add loop already assumes, and does not depend on the chunk boundary. Validation runs on every entry first, so an invalid earlier duplicate is still rejected (an earlier sketch deduplicated first and accepted it). Diffs: last-update-wins.diff, fix-with-test.diff (applies cleanly at ea57360912ed).
private List<Indexable> buildIndexables(List<MonitorQuery> updates) {
// commitWithoutNotify deletes each id once and then adds every entry, so keep only the last
// update of each id: a later update replaces an earlier one, as it does across commits
Map<String, MonitorQuery> lastUpdates = new LinkedHashMap<>();
for (MonitorQuery mq : updates) {
if (serializer != null && mq.getQueryString() == null) {
throw new IllegalArgumentException(
"Cannot add a MonitorQuery with a null string representation to a non-ephemeral Monitor");
}
lastUpdates.put(mq.getId(), mq);
}
List<Indexable> indexables = new ArrayList<>();
for (MonitorQuery mq : lastUpdates.values()) {
// ... unchanged: serialize, decompose, one document per branchTrade-offs
- Errors that only a dropped earlier entry would raise while it is serialized, decomposed or indexed are no longer raised, and
serializer.serializeis not called for it; only the null-query-string check runs on every entry. Not tested. LinkedHashMapkeeps each id at its first position, so surviving entries are indexed in first-occurrence order of their ids (read; no public result here depends on it).MonitorUpdateListener.afterUpdatestill receives the full list, duplicates included (unchanged).- Unchanged by the fix (read): a cache-only slot left by an earlier separate call (for example
q_1after a two-branch q is replaced by a one-branch q) stays cached until the normal purge; no index document refers to it, so matching is correct. - An index written by an affected version keeps its extra documents until each affected id is registered again or deleted (a later single
registerrepairs it, executed). Worth a CHANGES note. - Not reviewed by a committer.
What the checks validated
- Stock: the doc's test fails 4 of 4 with assertions on and off; with the fix it passes 4 of 4 (gradle and the harness).
- The whole monitor module with the fix: 33 classes, 185 tests, 0 failures, 1 skipped, assertions on and off.
:lucene:monitor:check -x testpasses (google-java-format, ecjLint, forbiddenApis).- Earlier hunt tests with the patched class shadowed, fresh seeds: the finder's 3, the independent reproducer's 6, the GPT-5.6 review stage's 2 (including its validation check) and the security probe's 4 (
-ea,-da) pass. The devil's advocate's probes: 3 of 4 pass; the fourth asserts the stock state and fails because q now matches {cherry}; all its printed views, includingConcurrentQueryLoader, are {cherry} with 1 disjunct.
A Set of ids in buildIndexables throws on the second occurrence (option-reject.diff, +5).
Trade-offs
The check sees one chunk only, so behaviour still depends on the 5001-entry boundary; checking the whole call needs every id of the call in memory, and earlier chunks are already committed. ConcurrentQueryLoader's worker catches only IOException and InterruptedException, so the exception ends it and stays in an unread Future; by code reading, with one worker nothing queued after that is registered, and once every worker has died add() blocks forever when the bounded queue (2000 by default) is full (a pre-existing loader weakness this option routes repeated ids into; not executed).
What the checks validated
The 32 upstream monitor test classes pass (181 tests, 0 failures, 1 skipped), so no upstream test relies on duplicates in one call. Probe: a one-call duplicate throws and registers nothing; the same id 5001 entries apart (q, 5000 other ids, q: 5002 entries) is accepted; ConcurrentQueryLoader (1 thread) fed q, q, r: close() returns normally and no id at all is registered.
A javadoc line on both register overloads and on ConcurrentQueryLoader.
Trade-offs
Leaves the silent mixed state, and the chunk-dependent behaviour, for every caller who does not read it. Not recommended.
Downstream: a narrowing update fails open. If the later version drops a topic or moves q to another metadata value (tenant a to b), the dropped part keeps matching, live or after a restart, while getQuery shows the narrowed query. Only the application's own batching triggers it; documents cannot. No exploit or such deployment is known.
Non-formal. While writing a TLA+ model of the Monitor's commit, purge and search protocol, an AI tool noticed in the code that one commit deletes each id once and then adds every update. The model treats one update per commit and did not find it; a public-API JUnit on the stock jars confirmed it.
The method in full153 words
Method: code reading (the hunt's non-formal tier), in a hunt unit (monitor and presearcher, 2026-10-26).
- The finder was writing a TLA+ model of the
WritableQueryIndexcommit, purge and search protocol (QueryIndexConc.tla). Its header says cache ids are modelled as query ids and decomposition is not modelled, and it treats one update per commit, so the model does not witness this bug. - Reading
commitWithoutNotifyfor the model, the finder saw the delete-each-id-once-then-add-everything loop and wrote a public-API JUnit, which failed on the stock main jars and on 10.5.2 and 9.12.3. - Then: a refute review by an independent reviewer from another model family (OpenAI GPT-5.6, reading only); an independent reproducer writing its own test from the claim alone; a GPT-5.6 "works as designed" review whose two side claims were executed; a devil's advocate; an adjudication; a release sweep, a prior-report search and a security screen; and this doc's own test and fix validation.
No prior report found (2026-10-26): 194 read-only queries over GitHub issues and PRs, commits, the dev@, java-user@ and issues@ lists, JIRA, Luwak and downstream repositories, plus every monitor issue and PR and every open PR's files. Closest: #14886, a different decomposition-order bug in the same class.
Searches5 searches
Verdict new. No issue, PR, commit, list thread, JIRA entry, Luwak issue or downstream report describes Monitor.register keeping both versions of an id registered twice in one call. #14886 (closed, fixed by #14885 in 10.3.0) shares the class and the id_ordinal cache-id scheme but concerns decomposition order across JVMs, and the bug reproduces on 10.5.2 and main, which contain that fix. Open PRs touching the files (#16847, #13948, #99) do not change the loop. Limits: keyword search only; GitHub's search rate limit was saturated, so GraphQL covered the same queries; private security reports are not searchable.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (migrated LUCENE-NNNN included), all of GitHub | 2026-10-26 | a scripted prior-report search (16 GitHub queries), 72 GraphQL and 24 REST searches; every module:monitor issue and PR (50) listed | #14886 / #14885 / #14967 (decomposition order), #679, #10830 and other monitor bugs; none is this bug |
| Open PRs | 2026-10-26 | the changed files of all 411 open apache/lucene PRs | #16847 (numDocs), #13948 (populateQueryCache reads), #99 (purge scheduling); none changes the loop |
| Predecessor and downstream | 2026-10-26 | all 191 flaxsearch/luwak issues and PRs; apache/solr, Elasticsearch and OpenSearch searches | luwak#21, #103 and solr#2382 are context only; Luwak's own commit loop is the same |
| Mailing lists and JIRA | 2026-10-26 | dev@, java-user@, issues@ (36 hand searches plus the tool's 20 list queries); 7 JQL queries, 3 issues read in full | no report; LUCENE-8766 (28 comments) and LUCENE-10422 read |
| The web | 2026-10-26 | 13 queries (tool and hand) | nothing relevant |
Present since the monitor module was added (LUCENE-8766, 2019, first released in 8.2.0); the loop came with the donated code and moved unchanged in 9.2.0. Executed on 10 releases from 8.2.0 to 10.5.2 and on main: all fail. Not a regression; not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-8766 "Add monitor subproject" (by @romseygeek, 2019-06-13): 251dbe7cea8 on the main line and the branch_8x backport b8773d13ad8, before Lucene used GitHub PRs (migrated as #9811). git log -L on QueryIndex.commit at 9.1.0 lists only 251dbe7cea8, and git blame -w -M -C -C on today's commitWithoutNotify puts 26 of its 27 lines on it. LUCENE-10422 (#679) moved the loop unchanged into WritableQueryIndex.commitWithoutNotify: main-line commit be991789564 (first tag 10.0.0), shipped in 9.2.0 through the branch_9x commit 72cf36a2b8d. #14885 (89bd9f0ecd5, 10.3.0) made the branch order stable, which changes which stale branch survives ({apple, cherry} in the recorded JDK 25 runs before, where the order came from a HashSet; {banana, cherry} after), not the bug. 8.8.2 to 8.11.3 are counted affected by inference between the executed 8.2.0 and 8.11.4. Release jars were run on JDK 25 only.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | this doc's test 4/4 fail (gradle and harness, -ea and -da); the finder's 3/3 |
| 10.3.0-10.5.2 | affected | 10.3.0 and 10.5.2 executed: {banana, cherry}; this doc's test 4/4 fail on 10.5.2 |
| 9.0.0-10.2.2 | affected | 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0 and 10.2.2 executed: {apple, cherry} (JDK 25); this doc's test 4/4 fail on 9.12.3 |
| 8.2.0-8.11.4 | affected | 8.2.0 and 8.11.4 executed (test-framework import adjusted): 3/3 fail |
| 8.1.1 and older | no module | no lucene-monitor artifact |
| fix (last update per id) on main | not affected | this doc's test and 185 monitor-module tests pass, -ea and -da |
An independent reproducer, a devil's advocate and an adjudication, each running tests on main, and a "works as designed" review from another model family (OpenAI GPT-5.6, reading only) say real; a GPT-5.6 refute review that could not run code said "not real" only procedurally. This doc's test was re-run on main, 10.5.2, 9.12.3 and with the fix.
Checks and verdicts416 words
Every check ran on main ea57360912ed; the GPT-5.6 reviews could read code but not run it, and a separate run re-ran the repro (it failed).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit 3/3 fail on main (-ea, -da), 10.5.2 and 9.12.3; pass with the fix sketch. |
| refute review (GPT-5.6, reading only) | not real, low (procedural) | Could not run any command, so it voted not real on procedure only; it agreed with the root cause and the inputs, rated the intended argument moderate. A separate run re-ran the repro: it failed. |
| Independent reproducer | real | Own JUnit outside the monitor package, from the claim alone: 4 of 6 fail on main (-ea, -da), 10.5.2 and 9.12.3, controls pass; disjunct count 3; purgeCache does not repair; a one-thread ConcurrentQueryLoader 20 of 20. |
| "works as designed" review (GPT-5.6, reading only) | real, medium | Intended argument moderate. Its two side claims were executed: a read-only Monitor matches differently ({apple,banana,cherry}), and the first fix sketch accepted an invalid earlier duplicate. |
| Devil's advocate | real, high | Rejected the "register only adds", "ids unique per call" and "startup loader" readings; three views disagree for 4 seeds; a later single register repairs; ConcurrentQueryLoader 30 of 30. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test (-ea and -da: 4 of 6 fail, controls pass), the finder's (3/3) and the finder's with the fix (3/3 pass). |
| credibility audit | keep | Re-ran the finder's test and its own test outside the package on main: fails exactly as claimed; score and scope checked. |
| doc re-runs (2026-10-26) | reproduced; fix validated | This doc's test fails 4/4 on main (gradle -ea, -da; harness), 10.5.2 and 9.12.3; the fix passes it and the 185 monitor-module tests. |
| Fact check of this doc (re-ran the test) | holds; three text fixes | Re-ran the brief's gradle command on a clean copy of main (assertions on and off: 4 of 4 fail), the harness on main (two seeds), 10.5.2, 9.12.3 and 9.2.0, the test from another package, and the fix (the test and 185 monitor-module tests pass, static checks pass). Found a truncated luwak#21 quote, a wrong count of two-call controls and an overstated 9.x scope; all fixed. |
| Cross-model fact check (OpenAI GPT-5.6, reading only) | central conclusion sound; 21 accuracy findings | Mechanism, line numbers, logs, history and fix confirmed; 8 errors, 8 overclaims and 5 missing caveats, among them the scope of "default", the 9.2.0 commit of LUCENE-10422 and the disclosure-route wording. Handled. |
Finding record, Lucene and upstream sources and repro files1,351 words
- Registry title
- Monitor.register with the same query id twice in one call keeps both versions in the query index, so the query matches a mix of both versions, and after a reopen a persistent Monitor runs the older version while getQuery returns the newer one
- Area
- monitor / query index updates (WritableQueryIndex.commitWithoutNotify and populateQueryCache, reached through both Monitor.register overloads and ConcurrentQueryLoader)
- Priority
- Low, rank 275: Needs two versions of one query id in a single register call of the optional monitor module.
- Impact class
- silent wrong results
- Impact note
- Applications of the optional lucene/monitor module that pass two versions of one query id to a single Monitor.register call (an update stream applied in batches, or ConcurrentQueryLoader draining a create and an edit together; both entries must fall into the same internal chunk of queryUpdateBufferSize + 1 = 5001 entries by default) get wrong matches with no error, with the default Monitor and MonitorConfiguration and at any scale (two entries suffice): the query keeps matching what only the older version matched when the older version decomposes into more disjuncts than the newer one or the update changes a presearcher filter field, getDisjunctCount is inflated, a persistent Monitor reopened on the index runs the older version while getQuery returns the newer one, and a read-only Monitor on the same index runs both, until the id is registered again in a separate call or deleted.
- Summary
- You register query id q twice in one call, monitor.register(List.of(new MonitorQuery("q", apple OR banana), new MonitorQuery("q", cherry))), as an application might when it applies a batch of query edits, and expect q to behave like one version, as it does when the two entries are registered in separate calls (the newer replaces the older). Instead the query index keeps both versions: q is reported for documents containing banana or cherry, a set that neither version matches, and getDisjunctCount is 3 for a one-clause query. With a persistent Monitor (q = apple, then q = cherry, in one call) the newer version runs until the Monitor is reopened, and from then on the older version apple runs instead while getQuery("q") still returns cherry.
- Introduced
- 251dbe7cea8126cb7ecadc921ba444c63d06c618 (main, 2019-06-13, 'LUCENE-8766: Add monitor subproject', by GitHub romseygeek; JIRA LUCENE-8766, migrated as GitHub issue #9811; no PR, pre-GitHub-PR era), shipped through the branch_8x commit b8773d13ad8e249a045862249bf476c30a444461 (same day). The 'delete each distinct id, then add every update' loop came with the code donated from the predecessor project Luwak (QueryIndex.commit, read, not executed). LUCENE-10422 / PR #679 (be991789564, 2022-03-21, first released in 9.2.0) moved it unchanged into WritableQueryIndex.commitWithoutNotify. [read: git log -L, git blame -w -M -C -C and identical normalized method bodies at 8.2.0, 8.8.1, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and the study base; exec at release granularity, see affected_versions]. Not a regression. (2019-06-13 (251dbe7cea8 on main and b8773d13ad8 on branch_8x))
- First affected version
- 8.2.0 [exec] (first release with the monitor module); there is no unaffected earlier release: 8.1.1 has no monitor module (lucene-monitor-8.1.1 does not exist on Maven Central)
- Affected versions
- Every release that ships lucene-monitor from 8.2.0 to 10.5.2 (the last release before 10.6.0), and main ea57360912ed. [exec] the finder's JUnit fails 3 of 3 on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.5.2 and main (seed DEADBEEF, -ea; main also -da; 8.x and 9.0.0 ran a copy that differs only in the test-framework import); the independent reproduction's JUnit fails 4 of 6 on main (-ea and -da), 10.5.2 and 9.12.3; the follow-up's security probe fails 3 of 4 as expected on main (-ea and -da) and 10.5.2 (-da). [read] the other releases by code identity, and 8.8.2 to 8.11.3 by inference between the executed 8.2.0 and 8.11.4 (not in the history clone). From 10.3.0 on, #14885 (stable decomposition order, fixing #14886) changes which stale branch of the older version survives in the repro (banana instead of apple), not the bug. Not fixed upstream: upstream main has the same loop (checked 2026-10-26) and no open PR changes commitWithoutNotify or buildIndexables [read]. Not executed: x86, the JDK of each release's era (all runs used JDK 25.0.3; the code is plain collections and IndexWriter calls).
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (searched 2026-10-26, by the follow-up step, read-only; the prior-report record): 194 distinct queries (236 logged), namely the scripted prior-report search (51 queries over GitHub, commits, downstream repositories, the mailing lists, the JIRA archive and the web), 72 GitHub GraphQL searches over apache/lucene, flaxsearch/luwak, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub, 24 GitHub REST searches, full listings of every module:monitor issue and PR (50), of the changed files of all 411 open apache/lucene PRs and of all 191 Luwak issues and PRs, 36 hand searches of dev@, java-user@ and issues@, 7 JQL queries plus 3 JIRA issues read in full, 7 web queries and the local git history; the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's own searches found nothing either. Not fixed upstream: upstream main has the same loop (2026-10-26); the open PRs that touch the files (#16847: numDocs, #13948: how populateQueryCache reads the stored query, #99: purge scheduling in Monitor.java) do not change it. Nearest item, not a report of this bug: #14886 (closed, fixed by #14885 in 10.3.0), decomposition order across JVMs with the same id_ordinal cacheId scheme. Gaps: keyword search only, and GitHub's search rate limit was saturated by parallel hunt searches (the same index was covered through GraphQL); 'not found' is a search result, not proof.
- Lucene commit tested
- main
ea57360912ed(2026-10-26): this doc's test through gradle with assertions on and off and through the repro harness, also on the 10.5.2 and 9.12.3 release jars; the finder's test on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.5.2 and main; the recommended fix in a scratch worktree atea57360912ed(removed afterwards): this doc's test and the whole monitor module (185 tests) with assertions on and off, and the module's static checks; every earlier hunt test with the fix shadowed; the alternative fix against the module's 181 upstream tests and a probe.
- Finding record
lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java :107-133 (commitWithoutNotify: ids collected into a Set :113-116, one delete per id :117-118, every entry added and cached :120-125), :137-170 (populateQueryCache: later documents of an id are skipped :158-162), :185 (buildIndexables, the fix site)lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java :51-60 (cacheId = id + '_' + ordinal); QueryIndex.java :62-72 (getQuery keeps the last document's query); ReadonlyQueryIndex.java :185-192 (each document's own stored query is deserialized)lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java :38-41 (a set of queries 'with associated IDs'), :146-176 (register overloads, 'Add new queries to the monitor', chunks of commitBatchSize + 1), :245-255 (getQuery: 'the MonitorQuery stored for this id'); MonitorConfiguration.java :35 (queryUpdateBufferSize 5000); ConcurrentQueryLoader.java :134-143 (the worker drains its queue into one register call)lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitor.java testUpdatesOverwriteOldQueries (replace-by-id across separate calls; cited by the finder, the independent reproduction, the intended-behaviour check and the GPT reviews)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:152-162 (chunking), 98 (commitBatchSize), 257-262 (getDisjunctCount)Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:35 (queryUpdateBufferSize 5000)Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:107-133 (commitWithoutNotify; ids :113-116, delete :117-119, add and cache :120-125), 152-172 (populateQueryCache; skip :158-162), 185-203 (buildIndexables), 380-386 (collector cache lookup)Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60; QueryIndex.java:62-72; ReadonlyQueryIndex.java:185-196
- Who is affected
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:127-128; ConcurrentQueryLoader.java:134-143; Monitor.java:152-162
- Why it looks unintended
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:38-41, 147-151, 168-176, 245-255; MonitorQuery.java:34-49; MonitorConfiguration.java:150-160; ConcurrentQueryLoader.java:34-52Lucene main ea57360912ed: lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitor.java:71-86; TestMonitorPersistence.java:54-87
- Suggested fix
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:102-105 (listeners get the full list); ConcurrentQueryLoader.java:134-151Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:63, 87, 103, 144, 146; WritableQueryIndex.java:185-203, 248-258 (purgeCache scans the index)
- TestRegisterDuplicateIdInOneCall.java (JUnit test):
repro/LFV-332/src/org/apache/lucene/monitor/TestRegisterDuplicateIdInOneCall.java(59 lines)