- Index two documents with a numeric doc-values field
val = 1and open an NRT readerr = DirectoryReader.open(w). It records the writer's version: 3. - Call
w.updateNumericDocValue(new Term("id", "1"), "val", 2), thenw.flush(), thenDirectoryReader.openIfChanged(r, w). - You expect
r.isCurrent()to be false and a new reader withval = 2. That is what happens without theflush(): the reopen showsval = 2at version 4. - Instead
r.isCurrent()is true andopenIfChangedreturns null, soSearcherManagerkeeps the stale searcher. A freshDirectoryReader.open(w)showsval = 2yet reports version 3:flush()wrote the update without bumping the version. - No error, and it lasts until something bumps the version. A soft delete made with
updateDocValues(if the segment keeps a live document) and aforceMerge(1)with nothing to merge do the same, and with awriteAllDeletes=truereader the reopen returns null althoughisCurrent()is false;hasUncommittedChanges()can return false for an update the nextcommit()still writes.
Applications that make doc-values-only changes (updates, or updateDocValues soft deletes that leave the segment a live document) and refresh NRT readers with openIfChanged, SearcherManager, ReaderManager or PrimaryNode, when flush(), a no-op forceMerge, a writeAllDeletes=true reader or reader-pool RAM pressure writes the changes. Searches silently read old values. Elasticsearch and OpenSearch are not exposed on their normal paths (from reading code).
Mechanism, terms and numbers882 words
Suppose the only changes since an NRT reader was opened are doc-values updates, or soft deletes made with updateDocValues that leave the segment at least one live document. If flush(), a forceMerge that has nothing to merge, or a writeAllDeletes reader writes those changes to disk, the reader is then reported current and DirectoryReader.openIfChanged(reader, writer) returns null. A fresh reader shows the change, yet SearcherManager keeps serving the old values.
It is deterministic and needs only the default config and a single document. The filed case fails on every tested release from 7.4.0 through 10.5.2, on branch_10_6 d04ee73177 (before 10.6.0) and on main; the 10.6.0 release (4ae946e5a9b) was not run. The same gap makes hasUncommittedChanges() return false for an update that the writer has applied but not yet written, since 7.0.0. It was filed as #16900. A three-file fix that records the change in IndexWriter.finishApply passes the repro and the full core test suite (one seed).
You keep a near-real-time (NRT) reader open on an IndexWriter and refresh it with DirectoryReader.openIfChanged(reader, writer). SearcherManager.maybeRefresh() ends in the same code: it calls openIfChanged(reader, commit) with a null commit by default, and for an NRT reader that also goes to the writer (StandardDirectoryReader.java:412-413). You change documents in place with doc-values updates, for example updateNumericDocValue, and at some point the writer is flushed.
- Set up. Index two documents, id 1 and id 2, each with a numeric doc-values field
val = 1. Openr = DirectoryReader.open(w). The reader records the writer's version number: 3. - Update. Call
w.updateNumericDocValue(new Term("id", "1"), "val", 2). - Without a flush, it works.
r.isCurrent()is false, andopenIfChanged(r, w)returns a new reader that showsval = 2, at version 4. - With a flush, it fails. Do the same, but call
w.flush()before reopening:r.isCurrent()says true.openIfChanged(r, w)returns null, which means "nothing changed".- A fresh
DirectoryReader.open(w)showsval = 2and also reports version 3, the same number as the stale readerr.
The same happens in these cases:
- Soft delete, then flush. The soft delete is made with
updateDocValueson the soft-deletes field. The deleted document stays visible: the old reader counts 2 documents, a fresh one 1. This needs the segment to keep a live document (here id 2). A soft delete of a segment's last live document makes the writer drop the empty segment with a version bump, and the reopen works. - A merge that has nothing to merge:
forceMerge(1)on an index that already has one segment. - A reader opened with writeAllDeletes = true, that is
DirectoryReader.open(writer, true, true). No flush is needed; that reopen itself returns null. - SearcherManager.
maybeRefresh()returns true, which its javadoc defines as "either refreshed or that there were no changes to refresh"; it returns true in the working case too. The symptom is thatacquire()keeps returning the stale searcher.
- The version is the reader's change stamp.
isCurrent()andopenIfChangedanswer "nothing changed" when the writer's version equals the reader's and nothing is pending in the writer (IndexWriter.nrtIsCurrent,IndexWriter.java:6140-6146). - A doc-values update goes through three stages. It is buffered, then applied (resolved to document numbers and parked in the writer's per-segment state, the reader pool), then written to a new doc-values file on disk.
- Applying it does not bump the version.
finishApplybumps it only when documents were deleted, or when it drops segments whose documents are all deleted (IndexWriter.java:6721-6724,:6726-6734). A doc-values-only packet does neither. While the update is parked, it counts as pending (readerPool.anyDocValuesChanges()), so the reader is correctly not current. - The normal reopen bumps it. It writes the parked update and calls
checkpoint(), which raises the version (IndexWriter.java:608,:4123-4126). That is step 3. - Three other paths write it without a bump.
flush(), aforceMergewith nothing to merge, and awriteAllDeletes=truereader all write the parked update throughReaderPool.commitand thencheckpointNoSIS(), which "does NOT bump segmentInfos.version" (IndexWriter.java:4119-4122,:4424,:2913-2916). The comment inReaderPool.commitsays the bump "was done previously (after we invoked BDS.applyDeletes)" (ReaderPool.java:363-369). That is true for deletes, not for doc-values updates. - After such a write, nothing is pending and the version is still 3. So the writer reports the reader as current, and
doOpenFromWriterreturns null (StandardDirectoryReader.java:462,:469-472). That is step 4. - Second symptom. The missing step also leaves the writer's change counter untouched. Commit, set a tiny RAM buffer so the update is applied at once, and update:
hasUncommittedChanges()returns false, yet the nextcommit()writesval = 2(IndexWriter.java:4225-4232). - A fourth path, with no flush at all. Under reader-pool RAM pressure,
writeSomeDocValuesUpdateswrites parked updates withcheckpointNoSIS()too (IndexWriter.java:854-859). It needs at least two update packets: the probe committed 200,000 documents, then, with a 0.5 MB buffer, sent one update matching all of them and 100,000 updates matching nothing. ThereisCurrent()is false, yetopenIfChangedreturns null, because the reader it opens has the same version as the old one and is discarded (StandardDirectoryReader.java:469-472), as in thewriteAllDeletescase.
Exposure and likelihood428 words
- Silent wrong results. There is no exception. Searches keep reading old doc-values, so anything built on them sees old values, such as sort keys, scoring signals, facets and doc-values filters (inferred from the stale values, not run feature by feature). Documents soft-deleted with
updateDocValuesstay visible. The stale state lasts until something bumps the version: an add, a hard delete that actually removes a document, a real merge or a commit. A delete that matches nothing does not end it, and repeated reopens keep returning null. - Default configuration. The filed steps exactly (the default
IndexWriterConfig, one document, no threads) return null on every release from 7.4.0 to 10.5.2, onbranch_10_6d04ee73177(before 10.6.0; the 10.6.0 release was not run) and on main. The hunt test fails 20 of 20 derived seeds for each of its 5 bug tests. The reopen forms were run only on one- and two-document indexes and in a 200,000-document RAM-pressure probe; nothing in the mechanism depends on size, but that is read from the code. The soft-delete form uses the opt-in soft-deletes field. ThewriteAllDeletesform uses the expertopen(writer, true, true). - Who is exposed. Applications that make doc-values-only changes and then do one of these:
- call
flush(); - call
forceMerge(1)orforceMergeDeletes()when there is nothing to merge; - open, anywhere on the same writer, a reader with
writeAllDeletes=true. This makes even a different, plain NRT reader stale; - send updates that hit reader-pool RAM pressure. That takes at least two update packets; the probe used one update matching 200,000 documents plus 100,000 that matched nothing, with a 0.5 MB buffer.
- call
- NRT helpers inherit it.
SearcherManagerandReaderManagerkeep the stale reader.ControlledRealTimeReopenThread.waitForGenerationreturns true although the update is not visible.- The replicator's
PrimaryNodeopens its manager withwriteAllDeletes=true(PrimaryNode.java:125). In the study, itsflushAndRefresh()did not publish a doc-values-only change ("version 3 -> 3").
- "Commit only if dirty" can lose updates. The study's sequence: tiny buffer, update,
if (hasUncommittedChanges()) commit(), thenrollback(). The commit was skipped and the update was lost. With the default 16 MB buffer, updating 1.5 million distinct documents once each madehasUncommittedChanges()false after update 1,470,089. - Downstream, from reading code only. Elasticsearch and OpenSearch are not exposed on their normal paths. They do not call
IndexWriter.flush()on the indexing path, they open readers withwriteAllDeletes=false, and every soft delete also adds a document. Applications built onPrimaryNodethat useupdateDocValuesare possibly exposed. Solr in-place updates followed by an optimize or expungeDeletes that merges nothing were only simulated at the Lucene level.
The test shows that after an NRT reader is opened, IndexWriter#updateNumericDocValue followed by flush() leaves DirectoryReader.openIfChanged(reader, writer) returning null, so the reader never sees the update.
/*
* 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.index;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestOpenIfChangedAfterDocValuesUpdateAndFlush extends LuceneTestCase {
public void testNumericUpdateThenFlush() throws IOException {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new StringField("id", "1", Field.Store.NO));
doc.add(new NumericDocValuesField("val", 1));
w.addDocument(doc);
try (DirectoryReader r = DirectoryReader.open(w)) {
w.updateNumericDocValue(new Term("id", "1"), "val", 2);
w.flush();
// expected: a new reader that sees val=2; actual: null, so the update stays invisible
DirectoryReader r2 = DirectoryReader.openIfChanged(r, w);
assertNotNull("expected a new reader after a doc-values update, but got null", r2);
r2.close();
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-001/, 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: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.index.TestOpenIfChangedAfterDocValuesUpdateAndFlush
- Expected vs actual
- Expected a new reader (the writer has an update the reader does not see); actual:
null, with-eaand-da(AssertionError: expected a new reader after a doc-values update, but got null).
More files, environment, full output41 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 a new reader after a doc-values update, but got null
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.assertTrue(Assert.java:42)
org.junit.Assert.assertNotNull(Assert.java:713)
org.apache.lucene.index.TestOpenIfChangedAfterDocValuesUpdateAndFlush.testNumericUpdateThenFlush(TestOpenIfChangedAfterDocValuesUpdateAndFlush.java:42)- Javadoc:
openIfChangedreturns null only "if there are no new changes";isCurrent()checks for "any new uncommitted changes" via the writer. - An extra call hides a change: without
flush()the update is seen; a hard delete plusflush()is detected. - 7.3.1 returns the new reader; it flipped at LUCENE-8290, a soft-deletes change. Similar misses (#6050, #6353) were fixed as bugs.
The full argument355 words
- The contracts say otherwise (
DirectoryReader.javaatea57360912ed):openIfChanged(reader, writer)returns a reader "searching both committed and uncommitted changes from the writer" (:304-306), and null only "if there are no new changes" (:334-335).- For an NRT reader,
isCurrent()"checks if either a new commit has occurred, or any new uncommitted changes have taken place via the writer" (:633-634). getVersion()"is advanced every time a change is made with IndexWriter" (:620-621).IndexWriter.hasUncommittedChanges()"Returns true if there may be changes that have not been committed". Its javadoc documents false positives and names no case where it returns false with uncommitted changes, though it does not promise that in so many words (IndexWriter.java:4219-4224). Here the nextcommit()does write the update, and LUCENE-5289 (#6353) fixed a false negative of this method as a bug.
- The code's own comment assumes the bump happened.
ReaderPool.commitsays the version bump "was done previously (after we invoked BDS.applyDeletes)" (ReaderPool.java:363-369). That holds for deletes only. - An extra call makes a change less visible. Without
flush()the update is seen. A hard delete followed byflush()is detected (version 3 to 4). So istryUpdateDocValuefollowed byflush(), because that method callschanged()(IndexWriter.java:1780-1782). - It used to work. On 7.3.1 the same steps return a new reader with
val = 2at version 4. The behaviour flips exactly at LUCENE-8290, a change about soft deletes whose issue never discussesisCurrent,openIfChangedor the version. - No test or document covers it. The two randomized stress tests that reopen readers accept a null reopen without comparing it with a fresh reader (
TestNumericDocValuesUpdates.java:1370-1374,TestMixedDocValuesUpdates.java:263-267), and they never flush after a doc-values update. Other tests do flush after doc-values updates (TestMixedDocValuesUpdates.java:531-546,:658-662), but then open a freshDirectoryReader.open(writer)(:560,:675) instead of reopening an existing reader, so they cannot see the staleness. No javadoc, CHANGES or MIGRATE entry describes it. - Precedents were fixed as bugs. #6050 (LUCENE-4986): an NRT reader missed
tryDeleteDocument. #6353 (LUCENE-5289):hasUncommittedChanges()returned false for a pending delete-by-term.
The devil's-advocate check260 words
The strongest case for "intended" was built by the independent reviewer:
- Writer-based NRT is
@lucene.experimental, with "no hard guarantee on how quickly you can get a new reader" (DirectoryReader.java:313-314). - The
IndexWriterclass javadoc says flushed changes "are not visible to IndexReader until either commit() or close is called" (IndexWriter.java:131-133). getVersion()first says it "returns the version recorded in the commit that the reader opened" (DirectoryReader.java:620), and only then that it "is advanced every time a change is made".- Lucene's own randomized tests tolerate a null reopen.
Why it fails:
- The latency caveat is about how fast a reopen happens, not about a reopen that reports "no changes" and never sees them.
- The flush sentence is unqualified, so it does conflict with the NRT contracts. It predates them: it was added in 2008 (LUCENE-1194,
5b113c8af63), a year before writer-based NRT readers (LUCENE-1516,3c91517add2, 2009), and its wording is the same today apart from formatting. The NRT javadocs are the more specific promise, and Lucene itself does not follow the flush sentence here: a fresh NRTDirectoryReader.open(writer)does see the change, and a hard delete across the sameflush()is detected. OnlyisCurrent()andopenIfChangeddisagree with the fresh reader. @lucene.experimentalweakens API stability, not the stated meaning of null.
The reviewer rated the argument "moderate" and the bug "real" with high confidence. One caution for the issue: the same javadoc still says "(though, the current implementation never returns null)" (DirectoryReader.java:306-307). That line is stale, so do not rely on it.
Recommended Fix B: record the change when doc-values updates are applied (finishApply)
In IndexWriter.finishApply, call checkpoint() when an applied packet resolved doc-values updates and deleted nothing, as deletes already do (3 files, +16/-2, package-private). It fixes every trigger and hasUncommittedChanges(). The repro, the 16 closest suites, the full core suite (8,871 tests, one seed) and the nightly replicator tests pass. Cost: one extra checkpoint per such packet, not benchmarked.
if (result.anyDeletes()) {
maybeMerge.set(true);
checkpoint();
} else if (result.anyDocValuesUpdates()) {
// Resolved doc values updates are a new NRT-visible change, just like deletes: bump the SIS
// version now, because the flush/commit/RAM-pressure paths that later write them to disk
// (ReaderPool#commit, writeSomeDocValuesUpdates) only call checkpointNoSIS():
checkpoint();
}All options and trade-offs5 options ยท 789 words
One fix, Fix B, was validated in a scratch worktree at ea57360912ed: it passes the repro, the closest suites and the full core suite. Fix A comes from the study and is known to be incomplete. The other options are untested sketches.
Fix B: record the change when doc-values updates are applied (finishApply) (validated by the checks) (recommended)
What it does:
FrozenBufferedUpdatessets a flag on the segment state when it adds a resolved update (:355).closeSegmentStatesreturns that flag inApplyDeletesResult.finishApplycallscheckpoint()when doc-values updates were resolved and nothing was deleted.
Three files, +16/-2, all package-private.
if (result.anyDeletes()) {
maybeMerge.set(true);
checkpoint();
} else if (result.anyDocValuesUpdates()) {
// Resolved doc values updates are a new NRT-visible change, just like deletes: bump the SIS
// version now, because the flush/commit/RAM-pressure paths that later write them to disk
// (ReaderPool#commit, writeSomeDocValuesUpdates) only call checkpointNoSIS():
checkpoint();
}Trade-offs
- Reach. It fixes every trigger (
flush(), no-opforceMerge,writeAllDeletesreaders, RAM pressure) and thehasUncommittedChanges()symptom, because it fixes the step they share. - Cost. One extra
checkpoint()(anIndexFileDeletercheckpoint) per applied packet that resolved doc-values updates, the same cost deletes already pay. Not benchmarked. - Version timing. The version now also moves when updates are applied. Reopen frequency should not change, because a parked update already makes
nrtIsCurrentfalse. That is reasoning from the code, not a measurement. - Two bumps per update on the normal reopen path. The version moves once when the packet is applied and again when
writeReaderPool(false)writes the update and callscheckpoint()(IndexWriter.java:4123-4126). A probe on main shows it: an update and a reopen without flush give version 4 unpatched and version 5 with Fix B (with-eaand-da). The reader-side checks compare versions for equality (IndexWriter.java:6143,StandardDirectoryReader.java:469), so the gap is probably harmless; no other user of the version was checked.
What the checks validated
At ea57360912ed, 2026-10-25, Gradle with JDK 25:
- Repro. 6 of 6 pass (seed DEADBEEF). With assertions off and
-Dtests.iters=10(seed 1234ABCD), 60 of 60 pass. Unpatched, the seed DEADBEEF command fails 5 of 6 with and without assertions; the 10-iteration command was not run unpatched. - Closest suites. 17 classes, 341 tests, 0 failures, 12 skipped (seed 5EED0001): TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestMixedDocValuesUpdates, TestIndexWriterReader, TestDirectoryReaderReopen, TestSoftDeletesRetentionMergePolicy, TestSoftDeletesDirectoryReaderWrapper, TestIndexWriter, TestIndexWriterDelete, TestReaderPool, TestPendingSoftDeletes, TestStressNRT, TestIndexWriterThreadsToSegments, TestDocValuesFieldUpdates, TestSearcherManager, TestControlledRealTimeReopenThread and the repro (so the count includes its 6 tests).
- Full core suite.
./gradlew :lucene:core:test: 8,871 tests, 0 failures, 323 skipped (seed 5EED0002; the repro file was in the tree, so this includes its 6 tests). - Nightly replicator.
:lucene:replicator:test -Dtests.nightly=true(seed 5EED0003): 15 tests, 0 failures, 1 skipped, including TestNRTReplication 13 of 13 and TestStressNRTReplication 1 of 1. - Format and lint.
ecjLintMainandecjLintTestpass.checkGoogleJavaFormatpasses for the three fixed files and flags only line wrapping in the repro test class; run./gradlew tidybefore using it in a PR. - Earlier, at the study commit
7cecf79523. The study report counts 32 new tests passing, including the RAM-pressure,PrimaryNodeandhasUncommittedChangestests (its logs show two runs, of 30 + 1 and of 31 tests). The full core suite: 8,895 tests, 0 failures. NightlyTestNRTReplication: 13 of 13. A TLA+ model of the version protocol: clean at 221,551 and 2,835,596 states. - Independent reviewer. Their own two test classes pass with it, and so do TestNumericDocValuesUpdates.testUpdatesAreFlushed, testReopen and TestIndexWriterReader.testIsCurrent.
Six lines, which restore what getReader did before 7.4. Before readerPool.commit, call readerPool.writeAllDocValuesUpdates() and then checkpoint().
if (writeDeletes) {
if (readerPool.writeAllDocValuesUpdates()) {
checkpoint();
}
if (readerPool.commit(segmentInfos)) {
checkpointNoSIS();
}
}Trade-offs
- Fixes
flush(), no-opforceMergeandwriteAllDeletes. - Leaves the RAM-pressure path (
writeSomeDocValuesUpdates) andhasUncommittedChanges()broken. At the study commit, 2 of the study's 30 new tests failed with it: the RAM-pressure test and a repeated-reopen probe. A TLA+ model found the hole first. - Not re-run at
ea57360912ed.
Let ReaderPool.commit report "doc-values updates written" separately from "live docs written", and call checkpoint() for the first. Do the same in writeSomeDocValuesUpdates. Untested sketch.
Trade-offs
Same reach as Fix A plus the RAM path. hasUncommittedChanges() stays wrong, because an applied but unwritten update is still not counted.
Untested sketch.
Trade-offs
Fixes only the second symptom, so it would have to come together with Fix A or the write-site option.
Compare per-segment doc-values and delete generations, instead of relying on the SegmentInfos version alone. Untested sketch.
Trade-offs
More invasive: it changes what "same version" means for readers and for doOpenFromWriter's equal-version shortcut.
Not attacker-driven: the application itself issues the updates, soft deletes and flushes, so the screen rates it outside Lucene's threat model, and it is already public as #16900. It can still matter downstream: if an application uses soft deletes for authorization, retention or privacy erasure, a stale reader keeps serving the "deleted" documents with no error.
AI-assisted randomized model-based testing found it: a randomized test of a hash-based postings format under development failed on some seeds. It was filed as #16900. A study re-found it with 14 methods, with the area hinted: fastest a stateful fuzzer (under a second) and a TLA+ model (a 2-step trace), which also found the hole in Fix A. Five static analyzers missed it.
The method in full305 words
Found by AI-assisted randomized model-based testing, without formal tools. A randomized test of a hash-based postings format under development, written and run with AI, failed on some seeds; the failures traced back to this reopen behaviour, and it was filed as #16900 on 2026-10-23.
Re-found by the 2026-10-22 study with 14 methods. None of these finds was blind: the task texts named the bug-note file pattern or listed the classes, so the area was hinted.
- Fastest. A bug-agnostic stateful fuzzer found it in under a second. A TLA+ model of the version protocol (
NrtVersion.tla) gave a 2-step trace forflush()and another forwriteAllDeletes. The same model later found the hole in Fix A. - Missed. Five static analyzers: none of about 2,170 index-package reports pointed at it.
- What every success had in common. An oracle that states the freshness contract ("null or
isCurrent()implies the same content as a fresh reader"). An operation set with a doc-values-only change plus a flush, a no-op forceMerge or awriteAllDeletesreader. And, in the models,checkpointandcheckpointNoSISkept as separate steps. - Second symptom. The
hasUncommittedChanges()form was reached first by Alloy and TLC model checking in sessions that had no hint of this symptom (the area itself was hinted). In the study's votes Alloy called it a new bug and TLA+ the same root cause; a follow-up adjudicated it as a second symptom of the same root cause: one fix atfinishApplyremoves both.
This hunt (2026-10-25):
- turned the study's tests into one minimal JUnit (5 bug tests and a control) and re-verified it on main
ea57360912ed, 10.5.2 and 9.12.3; - had it reviewed by an independent reviewer from another model family (OpenAI GPT-5.6), who wrote and ran their own repro;
- re-bisected the history and repeated the prior-report search;
- validated Fix B with Gradle.
No earlier report: 205 queries over GitHub issues and PRs, JIRA, commits, mailing lists, downstream code and the web, plus a scan of all 412 open PRs, found only #16900 (open, no comments on 2026-10-26). No open PR changes the fix sites. Add new material to #16900; do not file again.
Searches6 searches
No report predates #16900. On 2026-10-25 #16900 was open, labelled type:bug, with no comments and no linked PR; a read-only re-check on 2026-10-26 found it still open with 0 comments, and upstream main still at ea57360912ed. No open PR changes the fix sites. Add new material to #16900; do not file again.
| Where | When | How | Found |
|---|---|---|---|
| GitHub apache/lucene issues and PRs (open and closed, including migrated JIRA issues) | 2026-10-25 | 60 tool queries plus 18 manual searches, for example openIfChanged, isCurrent, nrtIsCurrent, checkpointNoSIS, writeReaderPool, finishApply, hasUncommittedChanges, updateNumericDocValue, writeAllDeletes, LUCENE-8290, LUCENE-7868, "openIfChanged returns null", "stale reader", "no changes to refresh" | only #16900 (ranked first, matched by 36 queries) |
| Open PRs, file-level scan | 2026-10-25 | all 412 open apache/lucene PRs, checked for lines at the fix sites | 27 touch the involved files; 0 change finishApply, checkpointNoSIS, writeReaderPool, nrtIsCurrent, doOpenFromWriter, hasUncommittedChanges or getVersion |
| Commit messages, JIRA archive, mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-25 | 6 commit queries, 15 plus 9 JIRA queries, 56 list queries plus 8 recent-window checks | nothing relevant; issues@ has only the notification of #16900 |
| Downstream repositories and the general web | 2026-10-25 | 5 tool and 9 manual downstream queries, 12 web searches | context only: an open Elasticsearch PR adding updatable doc-values fields reports no staleness; older Q&A threads predate LUCENE-8290 |
| Related items (read) | 2026-10-25 | the closest threads | different mechanisms: #6050 (LUCENE-4986), #4620 and #4625 (earlier nrtIsCurrent misses); #6353 (LUCENE-5289, the hasUncommittedChanges analog); #15831 (ReferenceManager lock race, refresh skipped rather than null) |
| Before filing (study and triple-check) | 2026-10-22/23 | about 420 study queries; 52 GitHub queries plus mailing lists and downstream repositories; 6 claim re-check queries | no report |
Introduced in 7.4.0 by LUCENE-8290 (#9337, 2018), which made flush() and writeAllDeletes readers write parked updates without a version bump. The gap underneath dates from LUCENE-7868 in 7.0.0 (RAM-pressure path, hasUncommittedChanges()); LUCENE-7976 (7.5.0) widened it by making forceMerge(1) of a lone segment a no-op. 6.6.6 is clean; each commit was checked against its parent.
Versions tested10 rows
Introduced by LUCENE-8290 "Keep soft deletes in sync with on-disk DocValues" (#9337). The master commit is 591fc6627ac and the branch_7x backport 8fdd3d7584b, authored 2018-05-02 and first released in 7.4.0. It made doFlush write parked updates through writeReaderPool(applyAllDeletes) (true for flush()), which ends in checkpointNoSIS(). It also replaced getReader's unconditional writeAllDocValuesUpdates() + checkpoint() with writeReaderPool(writeAllDeletes), so writeAllDeletes=true readers lost the bump; a default reopen still bumps.
The gap underneath is older. LUCENE-7868 (#8919, 58105a203a1, 2017-06-21, first released in 7.0.0) began parking applied updates in the reader pool without a version bump. It also added the RAM-pressure writer and the hasUncommittedChanges() symptom.
Two later changes only widened exposure. LUCENE-7976 (9c4e315c1cb, 7.5.0) made forceMerge(1) of a lone segment a no-op. LUCENE-8590 (eaf840867fa, 7.7.0) changed when a long update loop reaches the hasUncommittedChanges() gap.
All of these were found by running the same probe on lucene-core built at each commit and at its parent.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | JUnit 5 of 6 fail (Gradle and the harness, -ea and -da, 20 of 20 seeds); the probe shows all 9 bug scenarios |
branch_10_6 d04ee73177 (before 10.6.0), main b83101cdd2 | affected | flush case fails (2026-10-23 triple-check) |
| 7.5.0 to 10.5.2 (66 releases) | affected | flush, writeAllDeletes, soft delete, no-op forceMerge(1) and SearcherManager all stale; 10.5.2 and 9.12.3 also fail the hunt JUnit 5 of 6 |
| 7.4.0 | all but default forceMerge | flush, writeAllDeletes, soft delete and SearcherManager stale; forceMerge(1) still rewrote the segment by default, so it was detected (stale with compound files off) |
| 7.0.0 to 7.3.1 | older symptoms only | flush and writeAllDeletes correct (new reader, val = 2, version 4); the RAM-pressure reopen and hasUncommittedChanges() with a tiny buffer already fail; soft deletes have no API before 7.4.0 (soft-delete flush is OK at both LUCENE-8290 parent commits) |
| 6.6.6 | not affected | every scenario correct |
591fc6627ac (LUCENE-8290) vs parent 8b9c2a3185d; backport 8fdd3d7584b vs 0c89db84250 | bisect | flips exactly the flush-family scenarios on both branches |
58105a203a1 (LUCENE-7868) vs parent 1737fce5df1 | bisect | flips exactly the RAM-pressure and tiny-buffer hasUncommittedChanges scenarios |
9c4e315c1cb (LUCENE-7976) vs parent 20c1b7a24a8 | bisect | first commit where the default no-op forceMerge(1) is stale |
Fix B at ea57360912ed | not affected | repro 6 of 6 pass; full core suite 8,871 tests, 0 failures |
Confirmed by every study verifier that examined it, a triple-check with a claim re-check, the hunt verifier (main, 10.5.2, 9.12.3; 20 of 20 seeds) and an independent reviewer from another model family (OpenAI GPT-5.6). The triple-check refuted one draft claim: maybeRefresh() returns true, not false. Fact checks and a diagram check found text errors only, all fixed.
Checks and verdicts475 words
Independent checks, from the 2026-10-22 study to this hunt. Each row links to its record.
| Check | Verdict | What it found |
|---|---|---|
| Study verifiers | same root cause (adjudicated as a second symptom) | Proof audit, fuzzing, model-based testing, TLA+, Alloy, P, Dafny, JBMC, JML and concurrency checks attributed their finds to this root cause. For the hasUncommittedChanges() form, Alloy voted new bug and TLA+ same root cause; it was then adjudicated a second symptom. |
| Issue triple-check (lenses and a claim re-check) | confirmed, one claim refuted | Re-ran the draft on main and 6 releases. Refuted the draft's "maybeRefresh() returns false": it returns true and keeps the stale searcher. Noted that for writeAllDeletes readers isCurrent() is false and the null comes from the equal-version check. |
| Hunt verifier | real | The new JUnit reproduces on ea57360912ed with -ea and -da, on 10.5.2 and on 9.12.3, and on 20 of 20 seeds. Shadowing Fix B turns all 6 tests into passes. |
| Independent reviewer (OpenAI GPT-5.6, refute lens) | real, high confidence | Wrote and ran their own two test classes (flush case and control), also with an explicit production codec: the flush case fails and the controls pass. Rated the intended-behaviour case "moderate". Did not independently reproduce the soft-delete, forceMerge, writeAllDeletes and hasUncommittedChanges variants. A separate run re-ran their tests. |
| History follow-up | confirmed, registry corrected | Commit-level bisects of LUCENE-8290, LUCENE-7868 and LUCENE-7976. Corrected the registry: the loop variant of hasUncommittedChanges first fails in 7.7.0, while the tiny-buffer variant fails from 7.0.0. |
| Prior-art search | no earlier report | 205 queries and a scan of 412 open PRs: only #16900. |
| Fix validation (this doc) | Fix B passes | Repro, the 16 closest suites (17 classes with the repro) and the full core suite (one seed) pass at ea57360912ed. |
| Security screen | public is fine | The application issues the writer operations itself, which is outside Lucene's threat model; already public as #16900. |
| Fact check (independent AI tool) | bug confirmed; 12 low text errors fixed | Re-ran the repro on main (-ea, -da), on 10.5.2 and with the page's Gradle command in a fresh clone (5 of 6 fail each time), applied Fix B and reproduced every validation count, and checked every file:line, quote, commit and count. All 12 findings were wording or scope fixes; none changed a conclusion. |
| Fact check (cross-model reviewer) | bug confirmed; 6 medium text errors fixed | An independent reviewer from another model family (OpenAI GPT-5.6), read-only. Found 6 medium errors (soft-delete scope, the second checkpoint() in finishApply, the test-flush claim, the flush-javadoc rebuttal, the LUCENE-8290 routing, the unpatched-run sentence), 4 overclaims and 3 missing caveats; all were fixed. It rated the intended-behaviour case "moderate". |
| Diagram check | 2 text errors fixed, re-rendered | Checked every frame value and code reference against ea57360912ed with its own probe (call stacks, real files). |
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 426 KB
Finding record, Lucene and upstream sources, repro files and diagram notes733 words
- Registry title
- openIfChanged returns null and isCurrent() stays true after a flush when the only changes are doc-values updates
- Area
- index / IndexWriter NRT
- Priority
- High, rank 1: Top of High: the widest-reaching silent bug in the set, in the default NRT path since 7.x, where apps that do doc-values updates or updateDocValues soft deletes and reopen after a flush, a no-op forceMerge, a writeAllDeletes reader or an automatic RAM-pressure write keep a stale searcher that still shows soft-deleted docs (filed as #16900; outside fix PR #16923 is open).
- Impact class
- silent wrong results
- Impact note
- Anyone using NRT reopen (openIfChanged, SearcherManager, the replicator's PrimaryNode) with doc-values updates or soft deletes followed by flush(), a no-op forceMerge or a writeAllDeletes reader; default configuration, ordinary scale, released since 7.0.0/7.4.0.
- Summary
- You open an NRT reader, update a doc-values field (or soft-delete through updateDocValues), and then call flush(), a no-op forceMerge(1), or open readers with writeAllDeletes=true. You expect DirectoryReader.openIfChanged(reader, writer) to return a reader that sees the change. Instead it returns null and reader.isCurrent() is true, so SearcherManager keeps the stale searcher, and hasUncommittedChanges() can also return false while updates are uncommitted.
- Introduced
- LUCENE-8290 591fc6627ac (flush, no-op forceMerge and writeAllDeletes paths); LUCENE-7868 58105a203a19 (RAM-pressure path and the hasUncommittedChanges symptom, commit-level bisect) (2018-05-02 (LUCENE-8290, authored); 2017-06-21 (LUCENE-7868))
- First affected version
- 7.0.0 (RAM-pressure path and hasUncommittedChanges); 7.4.0 (flush and writeAllDeletes paths)
- Affected versions
- flush and open(w,true,true): every release 7.4.0-10.5.2 plus branch_10_6 d04ee73177 (2026-10-22, six commits before the 10.6.0 tag 4ae946e5a9b) and main [exec]; the 10.6.0 release itself was not run; no-op forceMerge(1) variant from 7.5.0 with the default config [exec]; RAM-pressure path and hasUncommittedChanges: 7.0.0+ [exec]; 7.3.1 and earlier correct for the flush case
- Status
- filed (an upstream issue or pull request was filed)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- non-formal baseline: randomized/MBT; also reached by TLA+/TLC
- Upstream and prior reports
- filed upstream as #16900 on 2026-10-23 (open, type:bug); before filing no report was found (study: about 420 more queries; triple-check: 52 GitHub queries plus mailing lists and downstream repos, and 6 claim re-check queries); open outside fix PR #16923 (diwu-sf, opened 2026-10-27, not yet reviewed) fixes it: our minimal repro passes on the PR head 1791cc8c0a0 with and without assertions and fails on its base 20ced71c1e6 (checked 2026-10-30)
- Lucene commit tested
- Main
ea57360912ed: the hunt JUnit, run with the repro harness (assertions on and off, 20 derived seeds) and with Gradle in a clean scratch worktree (2026-10-25). The 10.5.2 and 9.12.3 release jars. A plain-Java history probe on 16 releases from 6.6.6 to 10.5.2, on lucene-core built at 6 commits, and in 2 bisects. The 2026-10-23 triple-check on all 74 releases from 7.0.0 to 10.5.2, plus 6.6.6,branch_10_6d04ee73177and mainb83101cdd2. Fix B atea57360912ed. Not run: the 10.6.0 release (4ae946e5a9b).
- Finding record
SECURITY.md:28 (threat model)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:608, :2913-2920, :4119-4126, :4225-4232, :4424, :6140-6146, :6721-6724, :854-859Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:363-369; StandardDirectoryReader.java:462, :469-472Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6726-6734 (finishApply drops 100%-deleted segments); :4128-4142 (writeReaderPool drops them)Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:282-284; StandardDirectoryReader.java:412-413; search/SearcherManager.java:199-202; search/RefreshCommitSupplier.java:36-38
- Who is affected
Lucene main ea57360912ed: lucene/replicator/src/java/org/apache/lucene/replicator/nrt/PrimaryNode.java:125
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:304-307, :313-314, :334-335, :620-621, :633-634Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:131-133, :1780-1782, :4219-4224Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestNumericDocValuesUpdates.java:1370-1374; TestMixedDocValuesUpdates.java:263-267, :531-546, :560, :658-662, :675Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:618-621 (getVersion javadoc)
- TestOpenIfChangedAfterDocValuesUpdateAndFlush.java (JUnit test):
repro/LFV-001/src/org/apache/lucene/index/TestOpenIfChangedAfterDocValuesUpdateAndFlush.java(47 lines)
A picture of the writer's version number and of the reader through the steps of the tiny example: open, update, flush, reopen. Without the flush, the reopen raises the version to 4 and shows the update. With the flush, the version stays at 3 and the reopen returns null.
An independent diagram check compared every on-screen value, name and line reference with the code at ea57360912ed and with executed probes (its own probe printed the call stacks and used real files on disk).
assets/LFV-001/bug.png(426 KB)
