The Monitor keeps each query twice: as entries in its own small index, which pick the candidate queries for a document, and as cached queries named id_part (q2_0), which match() runs. An update first queues deletes for every id of the call, then caches and adds each new entry, with no rollback if an add fails.
q2_0 already holds the new query while the index still shows the old entry, q1 still matches its old version, and close() deletes both. Select the still to open it at full size. Generated with AI, checked against the code.- A Monitor stores queries by id, and registering an id again replaces its query. q2 =
bis registered in a Monitor that filters on a metadata fieldlang. You update it withregister(q2 = y)and a 40,000-bytelangvalue. IndexWriter refuses terms over 32,766 bytes, soregister()throws IllegalArgumentException. - You expect q2 to stay as it was, as it does when a call fails before anything is written: q2 is still
band matches the document "b". - Instead the Monitor had already queued the delete of q2's index entry and put
yinto q2's cache slotq2_0, which the old entry still points to.getQuery("q2")saysb, but q2 matches neither "b" nor "y". - The next, unrelated
register(q3)commits the queued delete:getQueryIds()is [q3].close()commits it too: afterregister(q2 rejected, q1 = c), close and reopen, the Monitor has no queries left: q1, valid but placed after q2 in the call, is gone as well. - The default configuration is hit too: a new query with a 32,765-character id deletes q1, placed after it in the same call, at the next register. Only the first call throws; the write that deletes the queries succeeds silently.
Users of the optional monitor module whose register call holds a document IndexWriter rejects after the deletes are queued: a filter-field value over 32,766 bytes (opt-in filter fields) or, with default settings, a ~32 KB query id. The rejected query and the queries after it in the chunk (up to 5,001) are lost; two queries suffice.
Mechanism, terms and numbers1,045 words
Monitor.register updates stored queries by id. When IndexWriter rejects one of the new query documents without aborting, for example because a TermFilteredPresearcher filter-field value is longer than 32,766 bytes, register() throws IllegalArgumentException, but WritableQueryIndex.commitWithoutNotify has already queued the delete of every query id in the chunk and, for each entry up to and including the rejected one, put the new version into its in-memory cache (and added the new documents before it); nothing is rolled back. Right after the failure the rejected query matches nothing while getQuery still returns its old version, and the queries after it still match their old versions. The next successful register or deleteById commits the queued deletes, and so does close() (default commitOnClose): the rejected query and the existing queries after it in the call are deleted, and the queries before it take their new version although the call threw. So a persistent Monitor can reopen without valid queries of the same call. With the default configuration a new query with a 32,765-byte id does the same to the queries after it. A call that fails earlier, while its documents are built, loses nothing. Two queries suffice, assertions on or off, and every release that ships the monitor module, 8.2.0 to 10.6.0 (10.6.0 by reading), and main are affected.
- 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. - Two copies of each query. Each query is split into parts by the
QueryDecomposer(for example one per clause of a plain disjunction; it keeps aBooleanQuerywhole whenminimumShouldMatch > 1, when it has several mandatory clauses, or mandatory and optional clauses together,QueryDecomposer.java:77-100; a term query is one part). Each part becomes one query-index document (the terms the presearcher selects on, the filter-field metadata, the serialized query, which is empty without a serializer,WritableQueryIndex.java:192,:198) and one cache entry namedid + "_" + part(QueryCacheEntry.java:56), so q2's only part isq2_0, in the old and in the new version. - Matching: the document's terms select candidate query-index documents; for each candidate the Monitor looks up its cache name and runs that cached query (
WritableQueryIndex.java:380-386).getQueryandgetQueryIdsread the query index (QueryIndex.java:62-72,Monitor.java:276-280). - Filter fields:
TermFilteredPresearcher(weightor, handlers, Set.of("lang"))checks a document only against queries with the samelangmetadata; the value is indexed as one term (TermFilteredPresearcher.java:244-252,KeywordAnalyzeratMonitorConfiguration.java:44). IndexWriter rejects terms over 32,766 bytes (IndexWriter.java:258,:1480-1481).
Monitor.registercommits its input in chunks of up toqueryUpdateBufferSize + 1= 5001 entries (Monitor.java:152-162,MonitorConfiguration.java:35; one more than the setter's javadoc describes, a harmless off-by-one).WritableQueryIndex.commitWithoutNotifybuilds every document first (buildIndexables,:185-202); serializer, decomposer and presearcher errors are thrown here, before any write, and lose nothing.- It then queues
writer.deleteDocuments(Term(_query_id, id))for every id of the chunk (:117-119), and for each part puts the new cache entry (:121) beforewriter.addDocument(:122). There is no catch and no rollback: the onlytry(:111-131) is afinallythat releasespurgeLock, so an exception skipswriter.commit()andmaybeRefresh()(:127-128). The add loop runs in input order and stops at the rejected entry. - IndexWriter rejects the document cleanly ("Document contains at least one immense term in field=\"lang\"") and stays usable, but the queued deletes and the documents added before the failure stay buffered.
- Right after the failure the searcher still shows the old documents and
getQuerythe old versions; whatmatch()runs depends on the position in the chunk. The rejected entry and the entries before it have their new cache entries: the oldq2_0entry (selected by "b") now runsy, so q2 matches nothing, and a valid update before it matches neither version in the probes (it can also over-match, see the Downstream note). Entries after the rejected one are untouched and still match their old version until the next write.purgeCache()does not repair it (it rebuilds the cache from the current cache,:249-256). - The next write (
registerordeleteById,:338-344) commits the buffered changes: queries before the rejected one switch to their new version; the rejected query and the existing queries after it lose both versions (a new id after it is never added). That holds for one-part queries, as in every executed case; by code reading, a multi-part query whose later part is rejected keeps the new parts added before it.close()closes the writer, whose config keeps the defaultcommitOnClose = true(MonitorConfiguration.java:43-50,IndexWriterConfig.java:102), so it commits them too.
| what you do | expected | got |
|---|---|---|
register(q2 = b), then register(q2 = y, lang = 40,000 x's) throws | q2 is b, matches "b" | getQuery = b, q2 matches nothing |
... then an unrelated register(q3) | ids {q2, q3} | ids {q3} |
persistent: register(q1 = a, q2 = b); register(q2 rejected, q1 = c) throws; close; reopen | ids {q1, q2} | ids {} |
default new Monitor(analyzer): register(q1 = a); register(<32,765-char id> = z, q1 = c) throws; register(q3) | q1 kept | q1 deleted |
control: register(q2 = <40,000-char term>), rejected while building documents; register(q3) | ids {q1, q2, q3} | as expected |
With the valid update before the rejected one, register(q1 = c, q2 rejected) over q1 = a, q2 = b: live, getQuery("q1") = a but q1 matches neither "a" nor "c"; after close and reopen q1 = c and q2 is gone. With the valid update after the rejected one (the default-configuration row), q1 still matches "a" right after the failure and is deleted by the next register. I/O variants: when the query index cannot create a file during register(q = b) over q = a (simulated full disk), q matches neither "a" nor "b", and the writer is closed, so the next register fails with AlreadyClosedException; in the failure-injection matrix (other queries) the same failure leaves getQuery at the old versions, and a reopened Monitor has them. A simulated sync failure at commit leaves the writer usable, and the next register commits the update.
Exposure and likelihood416 words
- Triggers (executed): a
TermFilteredPresearcherfilter-field metadata value longer than 32,766 UTF-8 bytes (filter fields are opt-in); with the default configuration, a query id whose cache id<id>_<part>is over the 32,766-byte doc-values limit while the id itself is at most 32,766 bytes, in the same call as other updates (contrived): for a one-part query an id of 32,765-32,766 bytes (32,764 passes); in generalUTF8(id).length + 1 + digits(part) > 32766(by code reading). An id of 32,767 bytes or more fails earlier, inwriter.deleteDocuments, and leaves its term in the writer's delete queue, so later writes andclose()throw: a separate root cause. An I/O error while the documents are written (simulatedcreateOutputfailure): in-session wrong matches; the writer then closes and later writes fail loudly. A simulatedsyncfailure at commit leaves the writer usable, and the next write commits the update. By construction also any customPresearcherwhose document IndexWriter rejects, for example one that copies an oversized query term into the document (not run). - Not triggers (executed with the default
TermFilteredPresearcher): an oversized term inside the query itself, a null query string, and serializer, decomposer or presearcher exceptions. They fail while the documents are built, before any write, and lose nothing. - Blast radius: every query id of the failed chunk, up to 5,001 with the default
queryUpdateBufferSize, gets a queued delete. The rejected query and the existing queries after it are lost; the queries before it take their new version although the call threw. - Default vs opt-in: the filter-field path needs the opt-in filter fields; the long-id path needs only
new Monitor(analyzer). In-memory Monitors lose the queries at the next write; persistent Monitors also atclose(). Assertions make no difference. - Scale: none; two queries suffice.
- Damage: silent loss of stored queries (alerts stop firing): the rejected query at once, the queries after it at the next write, then permanently; only the first call throws, and the deleting write that follows succeeds without error. Until then
getQueryandmatch()disagree for the rejected query and the updates before it. One in-session over-match was found (see the Downstream note). Not observed in the probes: structural index corruption, a hang, or an effect on ordinaryIndexSearchersearches. - How likely: rare (a judgement): it needs a value or id over 32 KB, or an I/O error, during a
register; no report was found in the searches below, although the code dates from 8.2.0 (2019).
The test shows that a Monitor.register call that IndexWriter rejects (q2 carries a 40,000-byte filter-field value) still deletes another query of the same call, q1, once the on-disk Monitor is closed.
/*
* 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.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestFailedRegisterDeletesOtherQuery extends LuceneTestCase {
public void testRejectedQueryDoesNotDeleteOtherQueryOfTheCall() throws IOException {
Path path = createTempDir();
try (Monitor monitor = open(path)) {
monitor.register(query("q1", "a", Map.of()));
expectThrows(
IllegalArgumentException.class,
() ->
monitor.register(
query("q2", "b", Map.of("lang", "x".repeat(40_000))),
query("q1", "c", Map.of())));
}
try (Monitor monitor = open(path)) {
// expected: q1 survives (old or new version); actual: [], close() committed its delete
assertEquals(Set.of("q1"), monitor.getQueryIds());
}
}
private static MonitorQuery query(String id, String term, Map<String, String> metadata) {
return new MonitorQuery(id, new TermQuery(new Term("f", term)), term, metadata);
}
private static Monitor open(Path path) throws IOException {
return new Monitor(
new StandardAnalyzer(),
new TermFilteredPresearcher(
TermFilteredPresearcher.DEFAULT_WEIGHTOR, List.of(), Set.of("lang")),
new MonitorConfiguration()
.setIndexPath(
path, MonitorQuerySerializer.fromParser(s -> new TermQuery(new Term("f", s)))));
}
}
# in a copy of this finding's repro folder (repro/LFV-499/, 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.TestFailedRegisterDeletesOtherQuery
- Expected vs actual
- Expected q1 to still be registered after close and reopen (
[q1]); actual: no queries (expected:<[q1]> but was:<[]>), with-eaand-da.
More files, environment, full output34 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: expected:<[q1]> but was:<[]>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.monitor.TestFailedRegisterDeletesOtherQuery.testRejectedQueryDoesNotDeleteOtherQueryOfTheCall(TestFailedRegisterDeletesOtherQuery.java:44)- IndexWriter rejects the document cleanly; its
updateDocumentsis all or none. The Monitor's own delete-first order loses the queries. - A call that fails earlier, while its documents are built, loses nothing; an upstream test expects queries to survive a
registerthat throws before any write. - No length limit is documented for metadata or ids, and afterwards
getQueryandmatch()disagree.
The full argument305 words
- Contracts.
register: "Add new queries to the monitor" (Monitor.java:147-151,:168-173), with no rollback or partial-failure semantics documented;getQuery: "@return the MonitorQuery stored for this id" (Monitor.java:245-255);matchcalls the matcher "for each possible matching query" (Monitor.java:207-209): after the failure q2 is stored asb,bmatches the document, andmatchdoes not report it.MonitorQuerymetadata: "Must not have any null values" (MonitorQuery.java:40-41), no length limit. - IndexWriter: after an exception in
addDocument"the index will be consistent, but this document may not have been added" (IndexWriter.java:1465-1466);updateDocuments"Atomically deletes ... and adds a block of documents ... such that an external reader will see all or none of the documents" (:1594-1596). On the same rejectionupdateDocumentskeeps the old version; a separate delete plus a failing add loses it (executed). - Tests.
TestMonitorPersistence#testCacheIsRepopulated(TestMonitorPersistence.java:54-87) registers 4 queries, has a fifthregisterthrow (a null query string, rejected before any write), reopens and asserts that all 4 are still there and still match. That is consistent with this report's expectation but shows it only for a failure before any write. No upstream test injects a failure into the write loop (TestMonitorErrorHandlingcovers match-time errors and theMonitorQuerynull-metadata check). - History. The delete-every-id-first loop came with the module (LUCENE-8766, 2019, code donated from Luwak) and moved unchanged in LUCENE-10422 (#679);
git log -S updateDocumentsand-S rollbackon the module find nothing. In Luwak the loop was created by the fix of luwak#68 (2015, a concurrency race); before it each query was one atomicupdateDocument(luwak#21). No discussion of a failing add was found. Context: in #11606 (2022) @hossman notes that the Monitor API works "to add+commit an entire batch all at once", so large batches are normal.
The devil's-advocate check231 words
The strongest case for "intended or misuse" (built by the devil's-advocate verifier; the GPT-5.6 reviews rated it moderate): register promises no atomicity and commits in chunks; filter fields are documented as "keyword fields" (package-info.java:65), so a 40,000-byte value is garbage in, and IndexWriter documents its 32,766-byte term limit; after the simulated createOutput IOException the writer is closed anyway.
Why it fails: it defeats only whole-call atomicity and the I/O variant. It does not explain why a rejected update later deletes the query's old version, why it deletes other, valid queries of the call, or why getQuery and match() disagree. The input is rejected only after the Monitor has acted on it; if oversized values are illegal, the fix is to reject them before the first delete. The same call failing a few lines earlier loses nothing, and nothing documents that the point of failure decides whether queries survive. The deletion is deferred to a later, successful, unrelated call. No won't-fix or by-design discussion was found. The case is strongest for the I/O variant: after a tragic createOutput failure the writer is closed and a re-created Monitor has the committed old versions (a GPT-5.6 review rated that case strong, the filter-field and long-id cases moderate); a sync failure at commit is not tragic. The claim rests on the non-aborting rejections. Verdict: a defect, claimed per query, not per call.
Recommended One atomic update per chunk; cache updated after it succeeds
Write each chunk with one atomic writer.updateDocuments(new TermInSetQuery(_query_id, ids), docs) and update the cache only after it succeeds (+8/-6 in WritableQueryIndex). A rejected chunk then changes nothing, as a chunk rejected while its documents are built does today. The repro, the whole monitor module (186 tests, assertions on and off) and the static checks pass.
if (indexables.size() > 0) {
Set<BytesRef> ids = new HashSet<>();
List<Document> docs = new ArrayList<>(indexables.size());
for (Indexable update : indexables) {
ids.add(new BytesRef(update.queryCacheEntry.queryId));
docs.add(update.document);
}
// one atomic update: if IndexWriter rejects any document, none of the deletes and none
// of the documents is applied, and the cache below is left unchanged
writer.updateDocuments(new TermInSetQuery(FIELDS.query_id, ids), docs);
for (Indexable update : indexables) {
this.queries.put(update.queryCacheEntry.cacheId, update.queryCacheEntry);
if (purgeCache != null)
purgeCache.put(update.queryCacheEntry.cacheId, update.queryCacheEntry);All options and trade-offs4 options · 1,023 words
Two fixes were built and validated in a scratch worktree at ea57360912ed (removed afterwards), plus an optional add-on for a failing commit. Workaround (partial): length-check filter-field values and ids before register (filter-field values at most 32,766 UTF-8 bytes; ids short enough that <id>_<part> stays within 32,766 bytes) and do not batch untrusted registrations of different users into one call; after a register that throws, register again, before any other write, what getQuery() returns for each id of the call (needs a serializer; executed on main and 10.5.2 for the filter-field case, with ids that existed before the call). It does not undo a new id placed before the rejected entry, whose buffered document the next write commits, and before 9.9.0 getQuery on an absent id can throw NullPointerException (GITHUB#12736).
One atomic update per chunk; cache updated after it succeeds (validated by the checks) (recommended)
Replace the per-id deletes and per-document adds with one IndexWriter.updateDocuments(Query, Iterable) call, the query form of the all-or-none update, and put the cache entries after it. A chunk IndexWriter rejects then changes nothing, exactly like a chunk that fails today while its documents are built, so callers get one rule. This is one policy choice (register documents no atomicity); the per-id option keeps more of a failed chunk. The cache is still updated before maybeRefresh(), and only commitWithoutNotify refreshes the writable searcher, under commitLock. Diffs: option-a.diff, option-a-with-test.diff (both apply cleanly at ea57360912ed).
if (indexables.size() > 0) {
Set<BytesRef> ids = new HashSet<>();
List<Document> docs = new ArrayList<>(indexables.size());
for (Indexable update : indexables) {
ids.add(new BytesRef(update.queryCacheEntry.queryId));
docs.add(update.document);
}
// one atomic update: if IndexWriter rejects any document, none of the deletes and none
// of the documents is applied, and the cache below is left unchanged
writer.updateDocuments(new TermInSetQuery(FIELDS.query_id, ids), docs);
for (Indexable update : indexables) {
this.queries.put(update.queryCacheEntry.cacheId, update.queryCacheEntry);
if (purgeCache != null)
purgeCache.put(update.queryCacheEntry.cacheId, update.queryCacheEntry);Trade-offs
- All or none per chunk: one bad entry also blocks the valid entries of its chunk, which keep their old versions (executed). That is today's behaviour for build-time failures. Earlier chunks of a
register(Iterable)with more than 5,001 entries are still committed before a later chunk fails (unchanged). - A chunk is added as one document block, kept in one segment, and the delete becomes one delete-by-query instead of one delete-by-term per id. Memory and speed were not measured. Both
updateDocumentsoverloads are@lucene.experimental. - Not covered: a failing
commit()(for examplefsync) after a successful update leaves the cache ahead of the searcher until the next successful commit (the remaining matrix case); see the add-on. - Unchanged: two versions of one id in one call (LFV-332) still keep both; that fix, in
buildIndexables, composes with this one. Queries already deleted by an affected version stay deleted. - Not tested: LFV-332's case together with this one, a concurrent same-id
deleteByIdorclear, an active purge (the purge-cache handling is kept, by code reading). - Not reviewed by a committer.
What the checks validated
- Stock: this doc's test fails 4 of 5 with assertions on and off; with the fix it passes 5 of 5 (gradle and the harness).
- The whole monitor module with the fix: 33 classes, 186 tests (181 upstream plus the 5 new ones), 0 failures, 1 skipped, assertions on and off.
:lucene:monitor:check -x testpasses (google-java-format, ecjLint, forbiddenApis); tidy leaves the patch unchanged.- With the fix shadowed on the main jars: the finder's two classes (4 tests, including the I/O test) and the independent reproducer's (6 tests) pass; a probe shows that updates still replace the old version, in memory and after a reopen, and that
deleteByIdworks; the fail-open probe passes; the failure-injection matrix drops from 2 to 1 bad case of 9.
On top of the recommended fix, wrap writer.commit(): if it throws, call manager.maybeRefresh() (suppressed into the exception) before rethrowing (option-a-refresh.diff is a diff against stock that already contains the recommended fix: +20/-7 in all, +12/-1 beyond it; apply it instead of option-a.diff, not on top of it).
Trade-offs
A register that threw at commit has its update visible in-session, and the next successful commit persists it; callers who treat any exception as "nothing happened" may be surprised. It catches Throwable, so it also refreshes after an Error or a tragic failure; that error-path policy needs review. A separate concern from the rejected-document bug.
What the checks validated
The failure-injection matrix has 0 bad cases of 9 (the recommended fix alone: 1); the whole monitor module passes (186 tests, assertions on).
Group the chunk's documents by id, call writer.updateDocuments(new Term(FIELDS.query_id, id), parts) per id and put that id's cache entries after it; on an exception commit and refresh what was applied, then rethrow (option-b.diff, +26/-10; it catches Throwable, so it also commits after an Error or a tragic failure, which needs review).
Trade-offs
The call is applied partly: ids before the rejected one take their new version, the rejected one and those after it keep the old one (executed), so callers must check which ids took effect. More code, with a commit in the error path. By code reading, if the failure closes the writer (an I/O error), ids applied earlier in the chunk keep their new cache entries while the searcher can no longer be refreshed (not executed).
What the checks validated
This doc's test 5 of 5, the finder's 4 and the independent reproducer's 6 pass with it; the whole monitor module passes (186 tests, assertions on); the fail-open probe passes; the matrix has 1 bad case of 9. Tidy re-wrapped one line; the diff is the tidied version.
In buildIndexables, throw IllegalArgumentException when a filter-field value or a cache id is longer than 32,766 UTF-8 bytes.
Trade-offs
Smallest change and a clearer error, but it covers only the known triggers: a custom presearcher's rejected document, other non-aborting IndexWriter rejections and I/O errors still go through the unprotected loop. A good complement to either fix. Not validated.
Downstream: the loss crosses queries. If an application batches several users' registrations into one call and copies their tags or ids unchecked, one user's oversized value silently deletes the saved queries after it in that call. Mostly fail-closed, but until the next write a valid update before the rejected one can match documents neither version matches. No exploit known.
Formal. An AI tool model-checked a TLA+ model of the Monitor's write path with a failing add: in a single-threaded run with two queries, "a failed register changes nothing" was violated, while the modelled fix held. A failure-injection run on the real jar and a public-API JUnit confirmed it.
The method in full272 words
Method: TLA+ model checking (TLC 1.8.0), in a hunt unit (monitor lifecycle and persistence, 2026-10-26).
- The model (
MonitorWrite.tla) covers the write protocol ofWritableQueryIndex: parts per query, non-aborting add failures, concurrentdeleteById/clear, close and reopen, with a map from each model element to its Java line. In the single-threaded configuration with a failing add (2 ids, up to 3 parts) TLC violatedFailureNoOp(after a register that threw, an id's visible documents are the ones it had before) andAtomicQuery(each id's visible documents are one whole version, and its cache entries are those documents' own). The modelled fix (FIX = TRUE,MonitorWrite.tla:29-31: one atomicupdateDocuments(Term(id), parts)per query with the cache puts after it, and the deletes ofdeleteById/clearunder the commit lock; close to the per-id option, not the recommended one) holds both, checked with concurrentregister,deleteByIdandclear(MC_fix_all, 13,440 distinct states). - A failure-injection matrix on the stock jar (9 injection x operation cases) found 2 broken cases, both in
register; operation-level model-based tests without failures found nothing. - Caveat: the hunt's record dates the find shortly before the first preserved TLC run, so the idea may have come while reading the code to write the model; model checking then formalized and reproduced it.
- Then: a refute review by an independent reviewer from another model family (OpenAI GPT-5.6, reading only); an independent reproducer; a GPT-5.6 "works as designed" review; a devil's advocate; an adjudication; a release sweep, a prior-report search, a security screen and an audit; and this doc's own test, fixes and probes.
No prior report found (2026-10-26): 251 read-only queries over GitHub issues, PRs and commits, the Lucene mailing lists, JIRA, the predecessor project Luwak (all 191 issues and PRs) and downstream projects. Closest: #14886, a different cache-id mismatch in the same class, fixed in 10.3.0. No open PR changes the code.
Searches5 searches
Verdict new (confidence high). None of the issues, PRs, commits, JIRA tickets, list threads, Luwak issues or downstream reports found by the searches below describes a Monitor.register whose add fails leaving the deletes of every id queued and the cache switched to the new versions. Closest, none a report: flaxsearch/luwak#68 (2015, a concurrency race whose fix created today's loop), luwak#21 (a rejected document when each query was still one atomic updateDocument), luwak#106 / #162 (rejections before any write), #14886 / #14885 (a cache id pointing at the wrong presearcher document after the decomposition order changed across JVMs; fixed in 10.3.0, and this bug reproduces on 10.5.2 and main), #11606 (a presearcher exception fails the batch before any write), #14427 / PR #14660 (filter-field semantics), solr#2382 (general commit/rollback concerns). Open module:monitor PRs #16847, #13948, #14660, #99, #13993 and #13995 do not change the loop. Limits: keyword search only; generic queries read to their first page; private security reports are not searchable.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits (migrated LUCENE-NNNN included), all of GitHub | 2026-10-26 | a scripted prior-report search (3 runs), 50 own GitHub searches incl. global searches for WritableQueryIndex and commitWithoutNotify; the module:monitor items read | #14886 / #14885, #11606, #14427, #12801, #679, #10830 and other monitor work; none is this bug |
| Open PRs | 2026-10-26 | the open module:monitor PRs checked file by file | #16847 (numDocs), #13948 (populateQueryCache), #14660 (javadocs), #99, #13993, #13995; none changes commitWithoutNotify |
| Predecessor and downstream | 2026-10-26 | all 191 flaxsearch/luwak issues and PRs; apache/solr, Elasticsearch and OpenSearch searches; two downstream monitor libraries | luwak#68 (origin of the loop), luwak#21, #106, #162, solr#2382: context only |
| Mailing lists and JIRA | 2026-10-26 | dev@, java-user@, issues@, commits@ (40 own searches plus the tool's list queries); 8 JQL queries | no report |
| The web | 2026-10-26 | the tool's web queries | nothing relevant |
Present since the monitor module was added (LUCENE-8766, 2019, first released in 8.2.0); the method moved unchanged in 9.2.0. Executed on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.5.2 and main: all fail. Not a regression; not fixed upstream.
Versions tested7 rows
Introduced by LUCENE-8766 "Add monitor subproject" (by @romseygeek, 2019-06-13): 251dbe7cea8 on the main line and the branch_8x twin b8773d13ad8, committed directly (apache/lucene-solr#648 was closed with "This has been merged."; JIRA LUCENE-8766, migrated as #9811). git blame -w -C -C -M puts every body line of commitWithoutNotify on 251dbe7cea8 and only the signature on be991789564, LUCENE-10422 (#679, first released in 9.2.0), which moved the body verbatim from QueryIndex.commit. The whitespace-stripped method body is identical in 54 refs (every 8.2.0-8.8.1, 9.x and 10.x tag, upstream main, branch_10x and branch_10_6 on 2026-10-26); 8.8.2-8.11.3 have the same delete / cache-put / add / commit call sequence by bytecode (exact source equality not checked). The pattern came with the donated Luwak code. Release jars were run on JDK 25 only; no x86 run.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | this doc's test 4/5 fail (gradle and harness, -ea and -da); the portable test 4/6 |
| 10.0.0-10.5.2 | affected | 10.0.0 and 10.5.2 executed: the portable test 4/6 fail; this doc's test 4/5 on 10.5.2 |
| 9.2.0-9.12.3 | affected | 9.2.0 and 9.12.3 executed: 4/6 fail; this doc's test 4/5 on 9.12.3 |
| 9.0.0-9.1.0 | affected | both executed: 4/6 fail; this doc's test 4/5 on 9.1.0 |
| 8.2.0-8.11.4 | affected | 8.2.0 (-ea and -da) and 8.11.4 executed: 4/6 fail, controls pass |
| 8.1.1 and older | no module | no lucene-monitor artifact |
| fix (one atomic update per chunk) on main | not affected | this doc's test and 186 monitor-module tests pass, -ea and -da |
An independent reproducer, a devil's advocate, an adjudication and an audit each ran tests on main and said real; a "works as designed" review from another model family (OpenAI GPT-5.6, reading only) said real; an earlier GPT-5.6 refute review said "not real" only because it could not run code. This doc's test, fixes and probes were run too.
Checks and verdicts318 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 4 of 4).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate (tier formal) | TLC violation of FailureNoOp and AtomicQuery; matrix 2 of 9 bad; JUnit 3/3 + 1/1 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; agreed with the root cause and the inputs, rated the intended argument moderate, and noted that whole-call atomicity is not promised. |
| Independent reproducer | real | Own JUnit from the claim alone: 5 of 6 fail on main (-ea, -da), 10.5.2 and 9.12.3, control passes; found the default-configuration 32,765-byte-id trigger; 8.11.4 4 of 5. |
| "works as designed" review (GPT-5.6, reading only) | real, medium | Intended argument moderate (no atomicity promise, chunked commits, keyword fields); root cause agreed; separate runs of the repro fail 4 of 4. |
| Devil's advocate | real (moderate-high; low for the I/O variant) | Re-ran the JUnit (3/3 fail); probes: updateDocuments keeps the old version under the same rejection; after the I/O error the writer is closed; an oversized query term fails before any write. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test (seed C0FFEE01, -ea and -da: 5 of 6 fail, control passes) and the finder's (4/4). |
| credibility audit | keep | Re-ran the finder's tests with -da (4/4 fail) and a new default-configuration probe (q1 lost after the next register, -ea and -da). |
| doc re-runs and fix validation (2026-10-26) | reproduced; two fixes validated | This doc's test fails 4/5 on main (gradle -ea, -da; harness), 10.5.2, 9.12.3 and 9.1.0; both fixes pass it and the 186 monitor-module tests; probes for replacement, partial chunks, the workaround and the fail-open window. |
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 414 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,619 words
- Registry title
- A Monitor.register call that IndexWriter rejects is not rolled back: the earlier versions of the queries in the call are deleted at the next commit or close(), and until then getQuery() and match() disagree
- Area
- monitor / query index updates (WritableQueryIndex.commitWithoutNotify failure atomicity, reached through both Monitor.register overloads)
- Priority
- Low, rank 276: The persisted query loss in the optional monitor module needs a rejected query document, in practice a filter value or query id of about 32 KB.
- Impact class
- data corruption
- Impact note
- Applications of the optional lucene/monitor module whose register() call contains a query document that IndexWriter.addDocument rejects without aborting (confirmed triggers: a TermFilteredPresearcher filter-field metadata value longer than 32766 UTF-8 bytes, an opt-in feature, and, with the default configuration, a query id of 32765-32766 bytes) silently lose stored queries: the previous version of the rejected query and of the other queries in the same commit chunk (up to 5001 by default) whose new documents had not been added yet are deleted at the next successful register or deleteById, or by close() (persisted for an on-disk Monitor), and until then the updated queries match nothing while getQuery() still returns their old versions; an I/O error during the write gives the same in-session missed matches with the default configuration, but the IndexWriter then closes and later writes fail loudly; two queries suffice, no scale is needed.
- Summary
- You update stored queries with Monitor.register and IndexWriter rejects one of the new query documents, for example register(q2 = f:y) where q2 carries a 40,000-byte value in a TermFilteredPresearcher filter field, so register() throws IllegalArgumentException. You expect the queries of the call to keep their old versions, as they do when the Monitor rejects a query before writing and as IndexWriter.updateDocuments would guarantee for each id. Instead the Monitor has already queued the deletion of the old version of every query id in the call and switched its in-memory cache to the new versions: right away q2 matches nothing although getQuery("q2") still returns f:b, and the next unrelated register or deleteById, or close() of a persistent Monitor, deletes the old versions for good (register(q2 rejected, q1 valid), then close and reopen, leaves neither q1 nor q2).
- Introduced
- 251dbe7cea8126cb7ecadc921ba444c63d06c618 (main, 2019-06-13, 'LUCENE-8766: Add monitor subproject', by GitHub romseygeek), with the branch_8x twin b8773d13ad8e249a045862249bf476c30a444461 (same message and time); submitted as apache/lucene-solr#648, which GitHub shows closed unmerged with 'This has been merged.' because the code was committed directly; JIRA LUCENE-8766 (migrated as #9811). It added QueryIndex.commit with the delete-every-id-first, cache-put-before-addDocument, no-rollback sequence. LUCENE-10422 / #679 (be991789564, first released in 9.2.0) moved the body verbatim into WritableQueryIndex.commitWithoutNotify. [read: git blame -w -C -C -M attributes every body line of commitWithoutNotify to 251dbe7cea8 and only the signature to be991789564; git log -L shows no other change; git log -S updateDocuments and -S rollback on lucene/monitor find nothing; exec at release granularity, see affected_versions]. Not a regression. The loop came with the code donated from the predecessor project Luwak, where the fix of flaxsearch/luwak#68 (luwak 18fc644, 2015) moved the deletes into commit() and so created it [read, outside Lucene]. (2019-06-13 (251dbe7cea8 on main and b8773d13ad8 on branch_8x))
- First affected version
- 8.2.0 [exec] (the first lucene-monitor release, tagged 2019-07-25); there is no unaffected earlier release: 8.1.1 has no monitor module (lucene-monitor 8.1.1 is not on Maven Central, HTTP 404, and the portable repro does not compile against 8.1.1)
- Affected versions
- Every release that ships lucene-monitor, 8.2.0 to 10.6.0 (10.6.0 by reading), plus main ea57360912ed, branch_10x and branch_10_6. [exec] the follow-up's portable public-API repro (3 filter-field checks, 1 I/O check, 2 controls) fails 4 of 6 with both controls passing on 8.2.0 (-ea and -da), 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the finder's JUnit fails 3 of 3 and its I/O test 1 of 1 on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.2.0 (9.1.0: the filter-field test only, 3 of 3); the independent reproduction's JUnit fails 5 of 6 on main (-ea and -da), 10.5.2 and 9.12.3, and 4 of 5 on 8.11.4 (import-adapted, I/O test not run; two checks fail there through the pre-9.9.0 NullPointerException of getQuery() on a deleted id, GITHUB#12736). [read] the whitespace-stripped method body is identical in 54 refs: both introducing commits, every release tag 8.2.0-8.8.1, every 9.x and 10.x release tag, origin/branch_10_6 2763db11b0a, origin/branch_10x 1309534296e, origin/main a9779ef3c19 (2026-10-26) and a Lucene main checkout; 8.8.2 to 8.11.3 by bytecode (the same call sequence as 8.2.0). Not fixed upstream (2026-10-26): no open PR changes commitWithoutNotify. Not executed: x86, and 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
- formal: TLA+/TLC
- Upstream and prior reports
- none found (searched 2026-10-26, by the follow-up step, read-only; the prior-report record): 251 logged queries, namely three scripted prior-report searches (75 + 33 + 33 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web), 50 own GitHub searches (apache/lucene, apache/solr, OpenSearch, Elasticsearch, flaxsearch/luwak and global searches for WritableQueryIndex and commitWithoutNotify), 40 own list searches, 8 JQL queries and 12 reads (all 191 Luwak issues and PRs, the module:monitor items, the open PRs' files); the finder's earlier scripted prior-report search (51 queries) and the intended-behaviour check's gh searches found nothing either. Not fixed upstream: no open PR changes commitWithoutNotify (open module:monitor PRs #16847, #13948, #14660, #99, #13993 and #13995 checked file by file), and the only upstream main commit under lucene/monitor since 2026-09-01 is the formatter change df6f71a3729. Nearest items, not reports of this bug: flaxsearch/luwak#68 (2015, predecessor project: a concurrency race whose fix created the current delete-every-id-first loop; nobody discussed a failing add), luwak#21 (an IndexWriter rejection while each query was still one atomic updateDocument), #14886 / #14885 (a cacheId pointing at the wrong presearcher document after the decomposition order changed across JVMs; different cause and fix site) and #11606 (a presearcher exception fails the whole batch, before any write). Gaps: keyword search only; GitHub's search rate limit slowed queries (two rate-limited queries were rerun); generic one-word queries were read to their first page only; private reports to security@lucene.apache.org are not visible.
- 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, 9.12.3 and 9.1.0 release jars; the follow-up's portable test on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.12.3, 10.0.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 (186 tests) with assertions on and off, and the module's static checks; the alternative fix and the add-on against the module; the finder's and the independent reproducer's tests, the failure-injection matrix and four probes with each fix shadowed on the main jars.
- Finding record
lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java :107-133 (commitWithoutNotify: deletes for every id :113-119, queries.put :121 before writer.addDocument :122, writer.commit :127 and maybeRefresh :128 skipped on an exception, no rollback), :185-202 (buildIndexables: every document built before any write; _cache_id and _query_id SortedDocValues)lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java :56 (cacheId = id + '_' + part, shared by every version); Monitor.java :147-176 (both register overloads, 'Add new queries to the monitor', chunks of commitBatchSize + 1); MonitorConfiguration.java :35 (queryUpdateBufferSize 5000), :43-50 (default IndexWriterConfig, KeywordAnalyzer, commitOnClose left at its default); TermFilteredPresearcher.java :244-252 (filter-field metadata indexed as a TextField)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java :1465-1466 (addDocument: after an exception 'the index will be consistent, but this document may not have been added'), :1594-1596 (updateDocuments: 'Atomically deletes ... and adds a block of documents ... all or none'); IndexWriterConfig.java :102 (DEFAULT_COMMIT_ON_CLOSE = true); lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitorPersistence.java :54-85 (testCacheIsRepopulated: earlier queries survive a register() that throws before any write)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:98, 152-162 (chunks), 174-176, 276-280 (getQueryIds)Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:35 (queryUpdateBufferSize 5000), 43-50 (default IndexWriterConfig, KeywordAnalyzer :44)Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:107-133 (commitWithoutNotify; deletes :117-119, cache put :121, addDocument :122, commit/refresh :127-128), 185-202 (buildIndexables), 249-256 (purgeCache), 338-344 (deleteQueries), 380-386 (collector cache lookup)Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-60 (cacheId :56); QueryIndex.java:62-72 (getQuery); TermFilteredPresearcher.java:244-252Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:258, 1480-1481 (term limit); IndexWriterConfig.java:102 (DEFAULT_COMMIT_ON_CLOSE)Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:77-100 (BooleanQuery kept whole); WritableQueryIndex.java:111-131 (try/finally), 192, 198 (empty _mq without a serializer); MonitorConfiguration.java:151-157 (setQueryUpdateBufferSize javadoc)
- Who is affected
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:152-162; MonitorConfiguration.java:35; TermFilteredPresearcher.java:77-79 (the default has no filter fields)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SortedDocValuesWriter.java:84-88 (32,766-byte limit)
- Why it looks unintended
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:147-151, 168-173, 207-209, 245-255; MonitorQuery.java:40-41; package-info.java:65-71Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1465-1466, 1594-1596Lucene main ea57360912ed: lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitorPersistence.java:54-87Lucene main ea57360912ed: lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitorErrorHandling.java:47-56 (null-metadata check)
- Suggested fix
Lucene main ea57360912ed: lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:107-133, 128 (the only maybeRefresh); QueryIndex.java:62-66 (getQuery needs a serializer)Lucene main ea57360912ed: lucene/CHANGES.txt:3272 (GITHUB#12736, Monitor.getQuery NPE for an absent id, fixed in 9.9.0)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1594-1623 (updateDocuments overloads); DocumentsWriterPerThread.java:296-306 (a failed block is dropped with its delete)
- TestFailedRegisterDeletesOtherQuery.java (JUnit test):
repro/LFV-499/src/org/apache/lucene/monitor/TestFailedRegisterDeletesOtherQuery.java(61 lines)
The summary card (PNG). Every probe value on screen is parsed from the executed diagram probe and asserted by the scene; the "expected" values come from the same probe with the recommended fix shadowed in (assertions on and off), which prints the same lines as with the finder's fix sketch.
assets/LFV-499/bug.png(414 KB)
![Summary card of one failed Monitor.register: a persistent Monitor holds q1 = f:a and q2 = f:b; register(q2 = f:y with a 40,000-byte lang value, q1 = f:c) throws IllegalArgumentException. A table compares expected (executed with the recommended fix) and actual after the failed call: getQuery("q2") is f:b in both; q2 matches a document f="b": expected yes, actual no; q1 matches a document f="a": yes in both; after close() and reopen, getQueryIds() is expected [q1, q2], actual []; after register(q3) instead, expected [q1, q2, q3], actual [q3]. Notes give the trigger, what is lost (the rejected query and every query of the chunk whose new document was not added yet), the affected releases (8.2.0 to 10.5.2 and main) and the recommended fix (one atomic updateDocuments per chunk, then the cache entries). A row of five boxes shows the steps of commitWithoutNotify: buildIndexables, deleteDocuments for every id, queries.put, addDocument throws with no rollback, and the next commit or close() applies the deletes.](../assets/LFV-499/bug.png)