All findings · Lucene finding · LFV-001 · openifchanged-null-after-dv-only-flush

openIfChanged returns null and isCurrent() stays true after a flush when the only changes are doc-values updates

If the only changes since an NRT reader opened are doc-values updates and the writer flushes, isCurrent() is true and openIfChanged returns null, although a fresh reader shows the change. SearcherManager keeps serving old values; it fails with the default config on every release from 7.4.0 to 10.5.2, on branch_10_6 before 10.6.0 and on main; the 10.6.0 release was not run.

Summary

Priority
High, rank 1Top 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
silent wrong results
Since
7.4.0 (flush(), writeAllDeletes); 7.0.0 (RAM pressure, hasUncommittedChanges())introduced 2018-05-02 · about 8 years
Affected
7.4.0 to 10.5.2, branch_10_6 d04ee73177 (before 10.6.0; the 10.6.0 release was not run) and main; 7.0.0+ for RAM pressure and hasUncommittedChanges()
Status
filed
Found by
AI-assisted randomized model-based testing; re-found by 14 methods (area hinted), including TLA+

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

Diagram of the bug on Lucene main. An NRT reader opened at writer version 3 shows val = 1. After updateNumericDocValue sets val = 2, the update is pending and the reader is not current. After flush() writes the update to disk without bumping the version, nothing is pending and the writer is still at version 3, so isCurrent() is true and openIfChanged returns null, while a fresh reader, also at version 3, shows val = 2.
The writer's version and the reader through open, update, flush and reopen. Without the flush the reopen raises the version to 4 and shows the update; with it the version stays at 3 and the reopen returns null. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index two documents with a numeric doc-values field val = 1 and open an NRT reader r = DirectoryReader.open(w). It records the writer's version: 3.
  2. Call w.updateNumericDocValue(new Term("id", "1"), "val", 2), then w.flush(), then DirectoryReader.openIfChanged(r, w).
  3. You expect r.isCurrent() to be false and a new reader with val = 2. That is what happens without the flush(): the reopen shows val = 2 at version 4.
  4. Instead r.isCurrent() is true and openIfChanged returns null, so SearcherManager keeps the stale searcher. A fresh DirectoryReader.open(w) shows val = 2 yet reports version 3: flush() wrote the update without bumping the version.
  5. No error, and it lasts until something bumps the version. A soft delete made with updateDocValues (if the segment keeps a live document) and a forceMerge(1) with nothing to merge do the same, and with a writeAllDeletes=true reader the reopen returns null although isCurrent() is false; hasUncommittedChanges() can return false for an update the next commit() still writes.

Who is affected

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

What you do

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.

A tiny example, executed on main

  1. Set up. Index two documents, id 1 and id 2, each with a numeric doc-values field val = 1. Open r = DirectoryReader.open(w). The reader records the writer's version number: 3.
  2. Update. Call w.updateNumericDocValue(new Term("id", "1"), "val", 2).
  3. Without a flush, it works. r.isCurrent() is false, and openIfChanged(r, w) returns a new reader that shows val = 2, at version 4.
  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) shows val = 2 and also reports version 3, the same number as the stale reader r.

The same happens in these cases:

  • Soft delete, then flush. The soft delete is made with updateDocValues on 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 that acquire() keeps returning the stale searcher.

Why: one version number, two ways to write

  • The version is the reader's change stamp. isCurrent() and openIfChanged answer "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. finishApply bumps 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(), a forceMerge with nothing to merge, and a writeAllDeletes=true reader all write the parked update through ReaderPool.commit and then checkpointNoSIS(), which "does NOT bump segmentInfos.version" (IndexWriter.java:4119-4122, :4424, :2913-2916). The comment in ReaderPool.commit says 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 doOpenFromWriter returns 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 next commit() writes val = 2 (IndexWriter.java:4225-4232).
  • A fourth path, with no flush at all. Under reader-pool RAM pressure, writeSomeDocValuesUpdates writes parked updates with checkpointNoSIS() 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. There isCurrent() is false, yet openIfChanged returns null, because the reader it opens has the same version as the old one and is discarded (StandardDirectoryReader.java:469-472), as in the writeAllDeletes case.
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 updateDocValues stay 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, on branch_10_6 d04ee73177 (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. The writeAllDeletes form uses the expert open(writer, true, true).
  • Who is exposed. Applications that make doc-values-only changes and then do one of these:
    • call flush();
    • call forceMerge(1) or forceMergeDeletes() 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.
  • NRT helpers inherit it.
    • SearcherManager and ReaderManager keep the stale reader.
    • ControlledRealTimeReopenThread.waitForGeneration returns true although the update is not visible.
    • The replicator's PrimaryNode opens its manager with writeAllDeletes=true (PrimaryNode.java:125). In the study, its flushAndRefresh() 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(), then rollback(). The commit was skipped and the update was lost. With the default 16 MB buffer, updating 1.5 million distinct documents once each made hasUncommittedChanges() 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 with writeAllDeletes=false, and every soft delete also adds a document. Applications built on PrimaryNode that use updateDocValues are possibly exposed. Solr in-place updates followed by an optimize or expungeDeletes that merges nothing were only simulated at the Lucene level.

Minimal repro

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.

TestOpenIfChangedAfterDocValuesUpdateAndFlush.java (JUnit test)47 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.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();
      }
    }
  }
}
Command
# 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 -ea and -da (AssertionError: expected a new reader after a doc-values update, but got null).

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

More files, environment, full output41 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: 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)

Why it looks unintended

  • Javadoc: openIfChanged returns 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 plus flush() 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.java at ea57360912ed):
    • 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 next commit() 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.commit says 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 by flush() is detected (version 3 to 4). So is tryUpdateDocValue followed by flush(), because that method calls changed() (IndexWriter.java:1780-1782).
  • It used to work. On 7.3.1 the same steps return a new reader with val = 2 at version 4. The behaviour flips exactly at LUCENE-8290, a change about soft deletes whose issue never discusses isCurrent, openIfChanged or 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 fresh DirectoryReader.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 IndexWriter class 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 NRT DirectoryReader.open(writer) does see the change, and a hard delete across the same flush() is detected. Only isCurrent() and openIfChanged disagree with the fresh reader.
  • @lucene.experimental weakens 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.

Suggested fix

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:

  • FrozenBufferedUpdates sets a flag on the segment state when it adds a resolved update (:355).
  • closeSegmentStates returns that flag in ApplyDeletesResult.
  • finishApply calls checkpoint() 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-op forceMerge, writeAllDeletes readers, RAM pressure) and the hasUncommittedChanges() symptom, because it fixes the step they share.
  • Cost. One extra checkpoint() (an IndexFileDeleter checkpoint) 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 nrtIsCurrent false. 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 calls checkpoint() (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 -ea and -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. ecjLintMain and ecjLintTest pass. checkGoogleJavaFormat passes for the three fixed files and flags only line wrapping in the repro test class; run ./gradlew tidy before using it in a PR.
  • Earlier, at the study commit 7cecf79523. The study report counts 32 new tests passing, including the RAM-pressure, PrimaryNode and hasUncommittedChanges tests (its logs show two runs, of 30 + 1 and of 31 tests). The full core suite: 8,895 tests, 0 failures. Nightly TestNRTReplication: 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.

Fix A: inside writeReaderPool(true), write the doc-values updates with a version bump first

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-op forceMerge and writeAllDeletes.
  • Leaves the RAM-pressure path (writeSomeDocValuesUpdates) and hasUncommittedChanges() 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.

Bump the version wherever doc-values files are written

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.

Make hasUncommittedChanges() also check readerPool.anyDocValuesChanges()

Untested sketch.

Trade-offs

Fixes only the second symptom, so it would have to come together with Fix A or the write-site option.

Detect the change on the reader side

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.

Downstream note

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.

How it was found

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 for flush() and another for writeAllDeletes. 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 a writeAllDeletes reader. And, in the models, checkpoint and checkpointNoSIS kept 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 at finishApply removes 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues and PRs (open and closed, including migrated JIRA issues)2026-10-2560 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 scan2026-10-25all 412 open apache/lucene PRs, checked for lines at the fix sites27 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-256 commit queries, 15 plus 9 JIRA queries, 56 list queries plus 8 recent-window checksnothing relevant; issues@ has only the notification of #16900
Downstream repositories and the general web2026-10-255 tool and 9 manual downstream queries, 12 web searchescontext 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-25the closest threadsdifferent 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/23about 420 study queries; 52 GitHub queries plus mailing lists and downstream repositories; 6 claim re-check queriesno report

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedJUnit 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 affectedflush case fails (2026-10-23 triple-check)
7.5.0 to 10.5.2 (66 releases) affectedflush, 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 forceMergeflush, 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 onlyflush 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 affectedevery scenario correct
591fc6627ac (LUCENE-8290) vs parent 8b9c2a3185d; backport 8fdd3d7584b vs 0c89db84250 bisectflips exactly the flush-family scenarios on both branches
58105a203a1 (LUCENE-7868) vs parent 1737fce5df1 bisectflips exactly the RAM-pressure and tiny-buffer hasUncommittedChanges scenarios
9c4e315c1cb (LUCENE-7976) vs parent 20c1b7a24a8 bisectfirst commit where the default no-op forceMerge(1) is stale
Fix B at ea57360912ed not affectedrepro 6 of 6 pass; full core suite 8,871 tests, 0 failures

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat 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 refutedRe-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 realThe 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 confidenceWrote 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 correctedCommit-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 report205 queries and a scan of 412 open PRs: only #16900.
Fix validation (this doc) Fix B passesRepro, the 16 closest suites (17 classes with the repro) and the full core suite (one seed) pass at ea57360912ed.
Security screen public is fineThe 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 fixedRe-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 fixedAn 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-renderedChecked every frame value and code reference against ea57360912ed with its own probe (call stacks, real files).

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes733 words

Finding record

LFV-001, snapshot of 2026-11-06

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_6 d04ee73177 and main b83101cdd2. Fix B at ea57360912ed. Not run: the 10.6.0 release (4ae946e5a9b).

Lucene and upstream sources

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-859
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ReaderPool.java:363-369; StandardDirectoryReader.java:462, :469-472
  • Lucene 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-634
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:131-133, :1780-1782, :4219-4224
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestNumericDocValuesUpdates.java:1370-1374; TestMixedDocValuesUpdates.java:263-267, :531-546, :560, :658-662, :675
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:618-621 (getVersion javadoc)

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

Repro files

About the diagrams

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

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.