All findings · Lucene finding · LFV-332 · monitor-register-duplicate-id-in-one-call-mixes-versions

Monitor.register with the same query id twice in one call keeps both versions of the query

Registering two versions of one query id in a single Monitor.register call keeps both. With q = apple OR banana then q = cherry, q matches banana and cherry, which neither version does, and after a restart a persistent Monitor runs the old version while getQuery shows the new one.

Summary

Priority
Low, rank 275Needs two versions of one query id in a single register call of the optional monitor module.
Impact
silent wrong results
Since
8.2.0 (LUCENE-8766, 2019)introduced 2019-06-13 · about 7 years
Affected
8.2.0 to 10.5.2 and main
Status
documented
Found by
code reading while modelling the commit protocol; JUnit-confirmed

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

The finding in 30 seconds

The Monitor (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.

  1. 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")).
  2. You expect q to behave like its newer version, as two separate calls do: q matches only the document "cherry", and getDisjunctCount() is 1.
  3. 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 in q_0 and banana stays in q_1.
  4. So q matches {banana, cherry}, a set neither version matches, and getDisjunctCount() is 3. No error, with assertions on or off.
  5. 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.

Who is affected

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.

Terms

  • 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): QueryDecomposer splits a query into branches, one per clause of a disjunction. Each branch becomes one query-index document and one cache entry named id + "_" + ordinal (QueryCacheEntry.java:51-60): apple OR banana gives q_0 = apple and q_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).

The bug

  1. Monitor.register commits its input in chunks of up to queryUpdateBufferSize + 1 = 5001 entries (Monitor.java:152-162, MonitorConfiguration.java:35).
  2. For one chunk, WritableQueryIndex.commitWithoutNotify collects 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).
  3. With two versions of q, the index keeps all 3 branch documents. Version 2's q_0 (cherry) overwrites version 1's q_0 (apple) in the cache; version 1's q_1 (banana) stays.
  4. Live: "apple" selects version 1's q_0 document, whose slot now runs cherry: no match; "banana" runs q_1 = banana; "cherry" runs version 2's q_0. Result {banana, cherry}; 3 disjuncts.
  5. Reopen: populateQueryCache rebuilds 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}.
  6. Read-only Monitor: each candidate document's own stored query is deserialized (ReadonlyQueryIndex.java:185-196), so both versions run: {apple, banana, cherry}.
  7. getQuery keeps the stored query of the last document collected (QueryIndex.java:62-72), here version 2's doc 2: cherry.

The numbers

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 register call and the same internal chunk of 5001 entries (default queryUpdateBufferSize 5000). Natural sources: an update stream or change log applied in batches (each register call commits and refreshes, WritableQueryIndex.java:127-128, so batching is the cheap way), or ConcurrentQueryLoader, whose worker passes everything it drained to one register call (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 default MonitorConfiguration and TermFilteredPresearcher, assertions on or off. The persistent views need an index path and a MonitorQuerySerializer, the read-only view a read-only directory provider, and the metadata case a TermFilteredPresearcher built 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. getQuery names a version the reopened Monitor does not run.
  • Bounds: separate calls are correct; deleteById removes every stale entry; a later single register repairs 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 on IndexSearcher results 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.

Minimal repro

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.

TestRegisterDuplicateIdInOneCall.java (JUnit test)59 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.monitor;

import java.util.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")));
    }
  }
}
Command
# 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 q to match like one version, [apple, banana] or [cherry]; actual [banana, cherry], with -ea and -da.

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

More files, environment, full output38 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: q 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)

Why it looks unintended

  • Monitor holds "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 ConcurrentQueryLoader passes its queue to one register call.
  • 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. Monitor class 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 in register, MonitorQuery (MonitorQuery.java:34-49), the package doc, CHANGES or MIGRATE.
  • The chunk size is a buffer setting: setQueryUpdateBufferSize sets "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) registers query1 as "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); populateQueryCache assumes 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's id_ordinal key, 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.

Suggested fix

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 branch
All 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.

Validate every entry, then keep the last update per id (validated by the checks) (recommended)

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 branch

Trade-offs

  • Errors that only a dropped earlier entry would raise while it is serialized, decomposed or indexed are no longer raised, and serializer.serialize is not called for it; only the null-query-string check runs on every entry. Not tested.
  • LinkedHashMap keeps 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.afterUpdate still 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_1 after 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 register repairs 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 test passes (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, including ConcurrentQueryLoader, are {cherry} with 1 disjunct.

Reject a repeated id with IllegalArgumentException (validated by the checks)

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.

Document that ids must be unique within one call

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 note

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.

How it was found

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 WritableQueryIndex commit, 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 commitWithoutNotify for 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (migrated LUCENE-NNNN included), all of GitHub2026-10-26a 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 PRs2026-10-26the changed files of all 411 open apache/lucene PRs#16847 (numDocs), #13948 (populateQueryCache reads), #99 (purge scheduling); none changes the loop
Predecessor and downstream2026-10-26all 191 flaxsearch/luwak issues and PRs; apache/solr, Elasticsearch and OpenSearch searchesluwak#21, #103 and solr#2382 are context only; Luwak's own commit loop is the same
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@ (36 hand searches plus the tool's 20 list queries); 7 JQL queries, 3 issues read in fullno report; LUCENE-8766 (28 comments) and LUCENE-10422 read
The web2026-10-2613 queries (tool and hand)nothing relevant

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test 4/4 fail (gradle and harness, -ea and -da); the finder's 3/3
10.3.0-10.5.2 affected10.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 affected9.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 affected8.2.0 and 8.11.4 executed (test-framework import adjusted): 3/3 fail
8.1.1 and older no moduleno lucene-monitor artifact
fix (last update per id) on main not affectedthis doc's test and 185 monitor-module tests pass, -ea and -da

Independent checks

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).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit 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 realOwn 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, mediumIntended 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, highRejected 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, highRe-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 keepRe-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 validatedThis 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 fixesRe-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 findingsMechanism, 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.

Sources and provenance

Finding record, Lucene and upstream sources and repro files1,351 words

Finding record

LFV-332, snapshot of 2026-11-06

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 at ea57360912ed (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.

Lucene and upstream sources

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-52
  • Lucene 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-151
  • Lucene 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)

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

Repro files

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.