All findings · Lucene finding · LFV-1060 · iw-hasuncommittedchanges-false-during-concurrent-flush

IndexWriter.hasUncommittedChanges() can return false for an uncommitted delete while another thread flushes

While another thread flushes or opens an NRT reader, hasUncommittedChanges() can return false for a still-uncommitted delete: it reads the change counter before the in-RAM buffers, and the flush moves the delete between them. The next call returns true; code that commits only on true skips a commit.

Summary

Priority
Low, rank 754A default-config IndexWriter status-predicate race present since 4.5.1 whose wrong false is transient and only matters to callers that decide whether to commit from it.
Impact
minor
Since
4.5.1 (LUCENE-5289, 2013)introduced 2013-10-17 · over 13 years
Affected
every release from 4.5.1 through 10.6.0 (read; 13 of them executed), and main
Status
documented
Found by
non-formal: a code-reading lens, then a TLA+ model and a JUnit

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

The finding in 30 seconds

The method checks two places, one after the other: first the change counter, then the RAM buffers. A flush moves a delete the other way, from the buffers into the counter. If the move happens between the two checks, the method sees the old counter and empty buffers, and answers false.

Background

  1. The change counter. IndexWriter counts applied changes in changeCount, and commit() remembers the value it committed. A new in-memory segment or an applied delete increments it.
  2. Buffered deletes. deleteDocuments records the delete in RAM and the counter does not move (unless buffered deletes use too much heap; then they are applied at once). A flush (flush(), opening or refreshing an NRT reader, a full RAM buffer) applies it to the segments, and only then does the counter go up. A flush does not commit.
  3. hasUncommittedChanges(). True if the counter differs from the last commit's value, or if RAM still holds changes. Its javadoc lists cases of a spurious true, none of a false.
Diagram of the bug: after a commit holding document id:1 and a buffered delete of it, thread B's hasUncommittedChanges() reads the change counter, which still equals the last commit's value; thread A's flush then applies the delete, increments the counter and removes the delete from RAM; B reads empty RAM and returns false although the delete is not committed. With the counter re-read after the RAM check, B returns true.
Two threads on one timeline: B reads the change counter (still the committed value), A's flush applies the delete, bumps the counter and empties RAM, then B reads empty RAM and answers false while the delete is still uncommitted. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Add a document id:1 and commit(): the last commit holds 1 document. Then deleteDocuments(new Term("id", "1")). The delete waits in RAM and the change counter does not move, so hasUncommittedChanges() is true only because of its RAM check.
  2. Thread B calls hasUncommittedChanges(). It first compares the change counter with the last commit's value: equal. Before B looks at RAM, thread A runs flush(), which applies the delete, increments the counter and then empties the buffers. It does not commit.
  3. You expect true: the delete was acknowledged before B's call, no commit ran, and a commit() now still writes it.
  4. Instead B finds the buffers empty and returns false; the next call returns true. The test forces this order and fails with assertions on and off, 10 of 10 seeds.
  5. Without hooks, two threads busy-polling during 19,211 flush rounds in 10 s made 302 wrong calls among about 2.56 billion, in 238 of the rounds; one call per flush round got 0 of 61,904. A caller that commits only on true skips that commit.

Who is affected

Applications that call hasUncommittedChanges() on one thread while another flushes, opens or refreshes an NRT reader, default config, every release since 4.5.1. The false is transient and the writer keeps the delete; only code that commits only on true skips a commit. Not seen with one check per flush (0 of 61,904); observable for a busy-polling caller.

Mechanism, terms and numbers861 words

IndexWriter.hasUncommittedChanges() evaluates changeCount.get() != lastCommitChangeCount || hasChangesInRam() without a lock: first the change counter, then the in-RAM buffers. A buffered delete is visible only to the second check, because deleteDocuments does not increment the counter (unless buffered deletes use too much heap; then they are applied at once). A flush (flush() and an NRT reader open were run; a refresh or a RAM-triggered apply takes the same path) applies the delete, increments the counter in checkpoint(), and only then removes the delete from the buffered-updates stream. A call that reads the counter before that checkpoint() and the buffers after the delete left them returns false, although the delete was acknowledged before the call and is still not committed. The next call returns true. The writer still holds the delete and the next commit() or close() writes it, so the impact is Low: only code that decides whether to commit from this method skips a commit, and the delete is lost only if rollback() or a crash comes first. The read order dates from LUCENE-5289 (2013) and is in every release from 4.5.1 through 10.6.0 (read in all 114 release tags; 13 of them were run and fail). Reading the counter again after an empty RAM check fixes the demonstrated case without adding a lock.

The code (main 645d4fe14a20)

  1. The method. IndexWriter.java:4225-4227, no lock: return changeCount.get() != lastCommitChangeCount || hasChangesInRam();. changeCount is an AtomicLong, lastCommitChangeCount a volatile long (:272-273). hasChangesInRam() is docWriter.anyChanges() || bufferedUpdatesStream.any() (:4230-4232): documents in RAM, the delete queue (read under its globalBufferLock), the flush tickets and the full-flush flag (DocumentsWriter.java:344-357, :376-378; DocumentsWriterDeleteQueue.java:181-196), then bytesUsed != 0 of the buffered-updates stream (BufferedUpdatesStream.java:108-110).
  2. A delete only buffers (for a small delete like the repro's). deleteDocuments(Term...) hands the term to the delete queue (IndexWriter.java:1864-1872); the diagram probe prints changeCount 4 = lastCommitChangeCount 4 after the delete. When buffered deletes use too much heap, DocumentsWriter.applyDeleteOrUpdate freezes them at once and deleteDocuments applies them before it returns (DocumentsWriter.java:153-186, IndexWriter.java:6345-6350); that is not the case shown here. An added document is different: creating its in-memory segment increments changeCount at once (:2173-2179), which explains why add-only changes were not seen to fail (0 of about 17,000 hook-free rounds; the tested case, not a proof for every add path).
  3. A flush moves the delete stage by stage. flush() runs doFlush (IndexWriter.java:4403-4413): flushAllThreads() freezes the delete into a flush ticket; publishFlushedSegments(true) pushes the frozen packet to the buffered-updates stream (IndexWriter.java:2928-2947), then lowers the ticket count (DocumentsWriterFlushQueue.java:99-105); processEvents(false) runs the packet's apply event, tryApply(packet). If another thread already holds the packet, tryApply returns and that thread applies it (IndexWriter.java:6491-6500); flush() then waits for all packets (:4417-4419, :4440-4452). Whoever applies it runs forceApply (:6508): updates.apply(segStates) (:6592), then finishApply (:6597) calls checkpoint() because a document was deleted (:6721-6724), which increments changeCount (:2923-2926); then bufferedUpdatesStream.finished(updates) (:6640) subtracts the packet's bytes (BufferedUpdatesStream.java:152-167). A delete packet that matches no document is finished with no checkpoint() (:6721; or it reaches no segment, :6562-6567, :6661-6664); that delete leaves nothing to commit. The NRT path getReader runs the same steps (IndexWriter.java:584-593).
  4. Each hop overlaps the next. The ticket count goes up before the freeze (DocumentsWriterFlushQueue.java:39: "first inc the ticket count - freeze opens a window for #anyChanges to fail"), the packet reaches the stream before the ticket count goes down, and the counter goes up before the packet leaves the stream. A reader that looks at the stages in the order the change travels (RAM first, then the counter) always finds a delete that matches a document.
  5. The method reads the other way round. It reads the last stage, the counter, first. If checkpoint() runs after that read and finished() before its stream read, it sees neither. The caller does not have to straddle a whole flush, only the short gap between checkpoint() and finished(), which is why a busy-polling caller can hit it.
  6. The sibling is safe. nrtIsCurrent (IndexWriter.java:6140-6146) is synchronized on the writer, like checkpoint() and the finished() call, so it cannot fall into this gap.

The numbers

The minimal test (one committed document, one delete, one flush()) returns false with assertions on and off, 10 of 10 random seeds. A TLA+ model of the two reads against one full flush gives a 16-state counterexample for the stock order and passes exhaustively for both fixes (105 and 117 distinct states); "exhaustively" holds for that model only (one matching delete, no buffered documents, one full flush; no commits, non-matching packets, merge retries or doc-values updates). Hook-free public-API runs with busy-polling threads, default config: 47 of 8,753 flush rounds in 10 s (adjudication, 63 wrong calls among about 2.94 billion); with 2 polling threads 238 of 19,211 flush rounds (302 wrong calls among about 2.56 billion) and 173 of 20,962 NRT-open rounds, 0 with the re-read fix; one call per flush round 0 of 61,904.

Exposure and likelihood289 words
  • Who. Code that calls hasUncommittedChanges() on one thread while another thread runs flush() or DirectoryReader.open(writer) (both run), or openIfChanged, SearcherManager.maybeRefresh or a RAM-triggered apply of deletes (the same path by reading, not run); default IndexWriterConfig. IndexWriter is documented as "completely thread safe, meaning multiple threads can call any of its methods, concurrently": that promises safe concurrent calls, not an atomic snapshot of the writer's state.
  • What breaks. A transient false for an acknowledged, uncommitted delete. Code that commits only when the method returns true (a periodic "commit if dirty" task, a shutdown step, a replication trigger) skips that commit.
  • What does not break. The writer still holds the delete: the next commit() or close() (commitOnClose defaults to true) writes it, a newly opened NRT reader already sees it, and searches are correct. The delete is lost only if the skipped commit was the last one before rollback() or a crash; otherwise a skipped commit only delays durability. No wrong search result, corruption or downstream incident was shown. In Lucene itself the only non-test caller is an assert in IndexUpgrader.
  • How often. Not seen with one check per flush: 0 of 61,904 rounds. Observable under sustained busy polling while another thread flushes: 47 of 8,753 rounds in 10 s; with NRT opens, 173 of 20,962 rounds. Each such round had at least one wrong call among billions of tight-loop calls (302 among about 2.56 billion in the tie-break flush run), so these are not rates for a periodic poller.
  • Scope. Deletes are shown; doc-values updates take the same path but were not executed, so they are not claimed. Add-only changes were not seen to fail (0 of about 17,000 rounds; the tested case, not a proof).

Minimal repro

One JUnit test, one method, default config, no sleeps. Concurrency is the bug, so it forces the order instead of racing: it holds the delete queue's lock, which is package-private for tests, so thread B waits between its two reads while thread A runs flush(). A hook-free public-API program shows the same false answer.

TestHasUncommittedChangesConcurrentFlush.java (one test)58 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.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
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 TestHasUncommittedChangesConcurrentFlush extends LuceneTestCase {

  public void testFlushBetweenChangeCountAndRamChecks() throws Exception {
    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));
      w.addDocument(doc);
      w.commit();
      w.deleteDocuments(new Term("id", "1"));

      ReentrantLock lock = w.getDocsWriter().deleteQueue.globalBufferLock;
      AtomicBoolean result = new AtomicBoolean();
      Thread checker = new Thread(() -> result.set(w.hasUncommittedChanges()));
      lock.lock();
      try {
        checker.start();
        while (checker.isAlive() && lock.hasQueuedThread(checker) == false) {
          Thread.onSpinWait();
        }
        w.flush();
      } finally {
        lock.unlock();
      }
      checker.join();

      // expected: true (the flushed delete is still uncommitted); actual: false
      assertTrue(result.get());
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestHasUncommittedChangesConcurrentFlush -Ptests.seed=DEADBEEF
Expected
hasUncommittedChanges() returns true: the delete is acknowledged and not committed.
Observed
It returns false: AssertionError at TestHasUncommittedChangesConcurrentFlush.java:55, with assertions on and off; the next call returns true.

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

More files, environment, full output217 words

The test sits in org.apache.lucene.index because it uses the delete queue's globalBufferLock ("pkg-private for access by tests"; Lucene's own TestDocumentsWriterDeleteQueue.testPartiallyAppliedGlobalSlice holds it the same way) and IndexWriter.getDocsWriter() ("for testing only"). The lock only stands in for thread B being descheduled between its counter read and its RAM read; flush() runs exactly as without it, because the lock is reentrant. So the test forces the schedule through package-private hooks and is not a public-API-only repro; the public-API program needs sustained busy polling to hit the window.

Regression test: this same test. It passes with either fix and can go into TestIndexWriter next to testHasUncommittedChanges. The bare AssertionError comes from the assertTrue without a message; a message does not change the result.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with either fix: passes. 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 release jars: fails
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestHasUncommittedChangesConcurrentFlush.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestHasUncommittedChangesConcurrentFlush -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestHasUncommittedChangesConcurrentFlush > testFlushBetweenChangeCountAndRamChecks FAILED
    java.lang.AssertionError
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:13DB0C8BB0ABC52C]:0)
        at org.junit.Assert.fail(Assert.java:87)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.junit.Assert.assertTrue(Assert.java:53)
        at org.apache.lucene.index.TestHasUncommittedChangesConcurrentFlush.testFlushBetweenChangeCountAndRamChecks(TestHasUncommittedChangesConcurrentFlush.java:55)

# with either fix: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • The javadoc lists only false positives (it "may return true when there are no actual 'real' changes"), never a false for an uncommitted change.
  • LUCENE-5289 (2013) fixed this same false for a buffered delete as a bug; its fix created the read order.
  • Lucene fixed the same in-transit window in nrtIsCurrent as bugs (LUCENE-3546, LUCENE-3551).
The full argument256 words
  • The contract. IndexWriter.java:4218-4224: "Returns true if there may be changes that have not been committed. There are cases where this may return true when there are no actual "real" changes to the index, for example if you've deleted by Term or Query but that Term or Query does not match any documents. Also, if a merge kicked off ... this method may return true right after you had just called commit." Every listed exception is a false positive.
  • The test. TestIndexWriter.testHasUncommittedChanges asserts true right after a buffered deleteDocuments (TestIndexWriter.java:2418-2419), sequentially.
  • The history. LUCENE-5289 (#6353) fixed the sequential form of this false as a bug and was backported to 4.5.1 because "Solr's replication depends on this feature" (Shalin Shekhar Mangar on #6353). Michael McCandless explained there: "It was failing to take into account any buffered changes that hadn't yet been 'resolved' (which increments the changeCount)." The fix (f9f0cdd56fd) added the RAM checks after the counter test. The javadoc's two "may"s both describe false positives: the merge caveat ("this method may return true right after you had just called commit") was added on the day of the fix (e1d5edea421, 2013-10-17, released in 4.5.1), and the hedge "there may be changes" with the no-match delete example came in a3c48b5ac41 ("improve javadocs", 2014-01-17, first in 4.7.0).
  • Precedent. LUCENE-3546 (#4620) and LUCENE-3551 (#4625, "Yet another race in IW#nrtIsCurrent") fixed the same kind of in-transit "no change" window in nrtIsCurrent; the comment at DocumentsWriterFlushQueue.java:39 comes from that work (Simon Willnauer's "window for #anyChanges" reasoning).
The devil's-advocate check261 words

The strongest case: the method is a deliberately cheap predicate that takes no writer lock. In LUCENE-5079 (#6143), which added it, Uwe Schindler argued for synchronized; Yonik Seeley chose a volatile read because it "might be nice to have on a GUI display", and Michael McCandless agreed ("volatile is best"). The javadoc says "may", and "thread safe" does not promise a linearizable answer. In a 2014 java-user thread Michael McCandless gave "Just history :)" and "maybe also paranoia/defensive" as the reasons commit() does not consult the method, and asked what the application was doing "that it so strongly relies on knowing whether commit() would do anything".

Why it fails: the cheap-read choice was made for a single comparison, changeCount != lastCommitChangeCount, which cannot give this answer; the two-source read came later, and neither fix adds a lock. Both "may"s describe false positives only. No linearizability is needed: in the case shown (a delete that matches a document, acknowledged before the call, no concurrent commit) the delete existed and stayed uncommitted for the whole call, so false matches no state the writer was in during the call. The same 2014 reply calls a wrong answer "a bug in hasUncommittedChanges", and LUCENE-5289 was fixed because downstream code gates commits on it. Verdict: not intended; it supports the Low impact, not "works as designed". A cross-model fact check rated the intended case moderate: the javadoc lists no false-negative exception but does not promise an atomic answer either, so the issue is best framed as a question (is the stronger behaviour wanted?).

Suggested fix

Recommended Re-read changeCount after an empty RAM check

Read the counter again after an empty RAM check: changeCount.get() != lastCommitChangeCount || hasChangesInRam() || changeCount.get() != lastCommitChangeCount. For a delete that matches a document, when the RAM check finds it gone the counter has already gone up. No added lock, and the cheap counter-first path stays. Validated: the repro passes, and so do 209 tests in 8 closest suites.

  public final boolean hasUncommittedChanges() {
    // a flush moves a change from RAM to changeCount (checkpoint) before it leaves RAM, so check
    // changeCount again after an empty RAM check
    return changeCount.get() != lastCommitChangeCount
        || hasChangesInRam()
        || changeCount.get() != lastCommitChangeCount;
  }
All options and trade-offs3 options · 475 words

Two one-statement fixes were validated in a scratch worktree of 645d4fe14a20. Both close the demonstrated case: for a delete that matches a document, checkpoint() increments the counter before the packet leaves the buffered-updates stream. A delete that matches no document is finished without a checkpoint(); a false is then not wrong, because that delete leaves nothing to commit. Neither was proven for concurrent commit(), rollback(), close or tragedy paths, merge retries or doc-values updates. A third option is not needed.

Re-read changeCount after an empty RAM check (validated by the checks) (recommended)

One file, +5/-1 in IndexWriter.hasUncommittedChanges(). Preferred by the adjudication and the intended-lens reviewer.

  public final boolean hasUncommittedChanges() {
    // a flush moves a change from RAM to changeCount (checkpoint) before it leaves RAM, so check
    // changeCount again after an empty RAM check
    return changeCount.get() != lastCommitChangeCount
        || hasChangesInRam()
        || changeCount.get() != lastCommitChangeCount;
  }

Trade-offs

One extra volatile read, only when the method is about to return false. It keeps the counter-first fast path, so no delete-queue lock is taken when the counter already differs, and it adds no lock (no writer lock; the RAM check takes the delete queue's lock as it does today), in line with the cheap read LUCENE-5079 chose.

What the checks validated

  • Stock: the minimal test fails with assertions on and off.
  • With the fix: the minimal test passes with assertions on and off.
  • Closest suites with the fix: TestIndexWriter (incl. testHasUncommittedChanges), TestIndexWriterCommit, TestIndexWriterDelete, TestIndexWriterReader, TestIndexWriterNRTIsCurrent, TestIndexWriterWithThreads, TestIndexWriterThreadsToSegments, TestDocumentsWriterDeleteQueue: 8 classes, 209 tests, 0 failures, 11 skipped.
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • Earlier evidence: TLC passes exhaustively for the model (117 distinct states), and the hook-free race shows 0 false answers in 25,579 flush and 34,622 NRT-open rounds.
  • Not run: nightly tests, benchmarks.
  • Not proven: concurrent commit(), rollback(), close or tragedy paths, merge retries, doc-values updates.

Read the RAM state first (validated by the checks)

return hasChangesInRam() || changeCount.get() != lastCommitChangeCount;: it reads the stages in the order the change travels.

Trade-offs

Always evaluates hasChangesInRam() first, which takes the delete queue's lock whenever no document is buffered, even when the counter alone would answer. The cost was not measured.

What the checks validated

  • With the swap: the minimal test passes with assertions on and off (same gradle run; closest suites not re-run for this option).
  • Earlier evidence: TLC passes exhaustively for the model (105 distinct states); the independent reproduction's looped race 0 of 22,150 rounds; testHasUncommittedChanges* still pass (refute review).
  • Same limits as the re-read fix.

Not needed: make the method synchronized

Like nrtIsCurrent, this would close the window.

Trade-offs

LUCENE-5079 chose a cheap read without the writer lock on purpose ("there is IO that happens when 'this' is locked", Michael McCandless on #6143), and neither fix above adds one.

Downstream note

Solr's update handler and the Elasticsearch and OpenSearch engines call hasUncommittedChanges() when deciding whether to commit; whether their own locking leaves this window open was not checked, and no downstream report of a skipped commit was found.

How it was found

A code-reading lens on read-order and check-then-act sites in IndexWriter flagged the order of the two reads. A choreographed probe and a hook-free race showed the false answer on main, 10.6.0 and 10.5.2; a TLA+ model and one deterministic JUnit followed.

The method in full138 words

Method: code reading, then executed checks (the study's "non-formal" tier: the first signal was a code-reading lens, the TLA+ model came after).

  • A lens on IndexWriter's check-then-act and read-order sites proposed that hasUncommittedChanges() reads its two sources in an order that a flush can slip between.
  • A choreographed probe (the checker parked between its two reads while another thread's flush ran) and a hook-free public-API race witnessed the false answer on main, the 10.6.0 RC1 and 10.5.2 (flush: 32 of 67,775 rounds; NRT open: 42 of 75,992; 0 with the fix).
  • A TLA+ model of the read sequence against one full flush (TLC): a 16-state counterexample for the stock order; exhaustive passes for the swapped order (105 distinct states) and the re-read (117).
  • The finder reduced it to one deterministic JUnit; independent checks then reproduced it (below).

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs (incl. the JIRA archive), the Lucene mailing lists or downstream trackers (searched 2026-10-30 and 2026-10-31). LUCENE-5289 (#6353) is the closest: the same false, sequential, fixed in 2013, and its fix created this read order.

Searches4 searches

Verdict new. Open PR #16948 removes a false positive at writer open, a different site.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues/PRs, commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, JIRA archive, web)2026-10-30 and 2026-10-31about 100 queries, then a 51-query refresh (1,843 distinct hits)0 high; #6353 at rank 2
GitHub, by hand2026-10-30 and 2026-10-31about 25 + 27 phrasings (hasUncommittedChanges, hasChangesInRam, lastCommitChangeCount race, anyChanges race, uncommitted delete, lost delete); the file lists of all 409 open PRs and the diffs of the 24 touching these filesno report; only #16948 mentions the method, for a false positive at open
lists.apache.org2026-10-31hasUncommittedChanges and uncommitted on dev@, java-user@, issues@, last 30 daysonly PR notifications for #16948 and #16643
Closest items (read)2026-10-30#6353, #6143, #4620, #4625, #5741, #16948, #16900none is this bug: a sequential predecessor, the method's origin, precedents in nrtIsCurrent, other sites, or the hunt's own different-root report

History

Introduced by LUCENE-5289 (f9f0cdd56fd, Michael McCandless, 2013-10-17), which fixed a sequential false by adding the RAM checks after the counter test; first released in 4.5.1. Every release from 4.5.1 through 10.6.0 has the read order (read in all 114 release tags), and the 13 releases that were run fail; not a regression. Not fixed upstream as of 2026-10-31.

Versions tested6 rows

Introduced by f9f0cdd56fd (trunk, LUCENE-5289 = #6353, a JIRA patch): return changeCount != lastCommitChangeCount; became ... || docWriter.anyChanges() || bufferedDeletesStream.any();. Backports 00e4c287e87 (4.5.1) and a80e7e93243 (4.6.0). Later commits kept the order; bc4da80776d (apache/lucene-solr#1460, 2020) extracted hasChangesInRam(), and git blame on main points there.

Before it: the method was added in 4.4.0 (LUCENE-5079). 4.4.0 and 4.5.0 return false for any buffered delete even with one thread (the LUCENE-5289 bug), so the concurrent-only form starts with 4.5.1.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone runner), 10 of 10 seeds; passes with either fix
upstream main 2f424fd428da, branch_11x, branch_10_6 readsame line (IndexWriter.java:4226; :4271 on branch_10_6)
10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe JUnit fails on the release jars
4.5.1, 4.6.0, 4.10.4, 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0 affecteda main() port with the same steps fails; 4.5.1 with the operands swapped passes
all 114 release tags 4.5.1 through 10.6.0 readthe same counter-first expression in hasUncommittedChanges()
4.4.0, 4.5.0 older bug (LUCENE-5289)false right after the delete even with one thread (LUCENE-5289, fixed in 4.5.1)

Independent checks

Confirmed by two executed cross-model reviews (OpenAI GPT-5.6), an independent reproducer, a devil's-advocate verifier, an adjudication, a credibility audit and a tie-break. A later re-review that could not run anything said not real on procedure only. This doc's gradle run reproduced it and validated both fixes. Fact checks (one executed, one cross-model) found text errors; they were fixed.

Checks and verdicts410 words

Every executed check says real. The one not-real record ran no code and says so itself; the second adjudication re-ran everything.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code-reading lens, TLA+ model, JUnit) candidateThe JUnit fails on main 20 of 20 seeds and on 10.6.0, 10.5.2, 9.12.3, 9.1.0; TLC counterexample, both fixes exhaustive; hook-free 11 of 10,387 rounds.
Refute review (OpenAI GPT-5.6, executed) real (high)Own deterministic test and own hook-free race fail on main with assertions on and off; no-flush controls pass; the swap passes.
Independent reproducer realOwn JUnit fails on main, 10.5.2 and 9.12.3; looped race 121 of 46,170 rounds, 0 of 22,150 with the swap; one call per round 0 of 61,904.
Intended-lens review (OpenAI GPT-5.6, executed) real (high)The intended-behaviour case is rated moderate and rejected; own test fails and passes with the re-read fix.
Intended-behaviour devil's advocate real (high)No intended, documented or misuse argument holds; the hook-free race fails on the released 10.6.0 core jar.
Adjudication confirmed (high), LowRe-ran the independent reproduction's test (fails, the re-read fix passes) and the race (47 of 8,753); prefers the re-read fix.
Credibility audit and no-noise gate keep; claim-lowRe-ran the JUnit and the race (flush 40 of 35,183, NRT 18 of 33,471); one minimal test.
Read-only re-review (OpenAI GPT-5.6, could not execute) not real (medium), proceduralIt could not run code; its root-cause reading agrees, and it calls its verdict "procedural rather than a proof".
Current-main verify and tie-break realAn independent test with four premise checks fails for flush and NRT open, 10 of 10 each; both controls pass; both fixes pass; also fails on 10.6.0 and 10.5.2.
This doc real; both fixes validatedGradle in a scratch worktree of 645d4fe14a20: stock fails; either fix passes the repro; the re-read fix passes 209 tests in 8 closest suites.
Independent fact and diagram checks real; errors fixedA fact check re-ran the minimal test on main (10 of 10 seeds, assertions on and off), on the 10.6.0, 10.0.0 and 9.12.3 jars and with both fixes, and the brief's own gradle command; a cross-model fact check (OpenAI GPT-5.6) re-read the citations. Their errors (a line range, the javadoc history of "may", absolute wording about deletes, the apply path, the fixes' locking and correctness argument, the executed-release claim, a SECURITY.md citation) were fixed; the diagram check found no error.

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 notes1,208 words

Finding record

LFV-1060, snapshot of 2026-11-06

Registry title
IndexWriter.hasUncommittedChanges() can return false for an acknowledged, uncommitted delete while another thread flushes or refreshes an NRT reader, because it reads changeCount before the in-RAM state (every release since 4.5.1)
Area
index / IndexWriter (hasUncommittedChanges: changeCount vs. buffered deletes in flight)
Priority
Low, rank 754: A default-config IndexWriter status-predicate race present since 4.5.1 whose wrong false is transient and only matters to callers that decide whether to commit from it.
Impact class
minor
Impact note
Applications that call hasUncommittedChanges() on one thread while another thread's flush(), NRT reader open or refresh, or RAM-triggered flush applies a buffered delete, default config: the call can return false although an acknowledged delete is still uncommitted; the next call returns true and the writer still holds the delete, so the next commit() or close() (commitOnClose defaults to true) commits it, and only an application that commits only when the method returns true skips that commit (the delete is then lost only if rollback() or a crash follows before a later commit); a single call per concurrent flush hit the window 0 times in 61,904 rounds, a caller that polls during flushes hits it regularly (47 of 8,753 rounds in 10 s); outside tests, only an assert in IndexUpgrader calls the method.
Summary
You delete a committed document with deleteDocuments, do not commit, and call hasUncommittedChanges() while another thread calls flush() or refreshes an NRT reader; you expect true, because the delete was acknowledged and is not in the last commit, and the javadoc allows only false positives. Instead the method reads changeCount first (still equal to the last commit's) and the in-RAM state second, and if the flush applies the delete, increments changeCount and empties the delete buffers between those two reads, it returns false; the next call returns true. A caller that commits only when the method returns true skips that commit.
Introduced
f9f0cdd56fdc550d193db5c841571d7bbb0ff6cb (trunk, svn r1533164, LUCENE-5289 = #6353 'IndexWriter.hasUncommittedChanges was returning false when only deletes were buffered', by Michael McCandless; a JIRA patch, not a GitHub PR): it appended '|| docWriter.anyChanges() || bufferedDeletesStream.any()' after the changeCount test, which created the read order; backports 00e4c287e87 (lucene_solr_4_5, 4.5.1) and a80e7e93243 (branch_4x, 4.6.0). Later commits kept the order (bc4da80776d, apache/lucene-solr#1460, 2020, extracted hasChangesInRam(); git blame on main points at it) [read: full history; exec: 4.4.0 and 4.5.0 return false for a buffered delete even single-threaded (the LUCENE-5289 bug), 4.5.1 fails the concurrent port and passes with the operands swapped]. Not a regression. (2013-10-17 (author date of f9f0cdd56fd and both backports))
First affected version
4.5.1 [exec] (lucene_solr_4_5 backport; also 4.6.0 from branch_4x). The method first appeared in 4.4.0 (LUCENE-5079), but 4.4.0 and 4.5.0 return false for any buffered-only delete even without concurrency (LUCENE-5289, fixed), so the concurrent-only form starts with 4.5.1
Affected versions
every release from 4.5.1 through 10.6.0, and main. [exec] the minimal JUnit fails on main 645d4fe14a20 (-ea and -da, seed DEADBEEF; 20 of 20 random seeds; re-run: -ea seed 5EED4641 and -da seed 5EED4642 fail), on the 10.6.0 release jars (4ae946e5a9b; -ea and -da), 10.5.2 (-da), 10.0.0 (-da), 9.12.3 (-ea and -da) and 9.1.0 (-da); the independent reproduction's own JUnit fails on main (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3 (-da); a main() port with the same steps fails on 4.5.1 (-ea and -da), 4.6.0, 4.10.4, 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.6.0 and main and passes with the operands swapped on 4.5.1 and main; the hook-free public-API race shows the false return on main, on the 10.6.0 RC1 and released core jars and on 10.5.2. [read] upstream main 716d6f216f5 (head at 2026-10-29) still has the same line at IndexWriter.java:4226 (re-read 2026-10-30); the last commit on IndexWriter.java is 169cbffd12f (author date 2026-09-21). Not new in 10.6.
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 and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading; also reached by TLA+/TLC
Upstream and prior reports
none found (2026-10-30, read-only). The prior-report record (verdict new): two scripted prior-report searches (about 100 queries over GitHub issues and PRs, commits, Elasticsearch, OpenSearch and Solr, dev@, java-user@, issues@ and commits@, the JIRA archive and the web), about 25 manual GitHub searches, the changed files and diffs of all 400 open apache/lucene PRs (only #16948 mentions hasUncommittedChanges, in tests, for a false positive at open), every apache/lucene issue created since 2026-10-20, and upstream main, branch_10x and branch_10_6 commits since 2026-10-20 on the four fix-site files (none). Closest items, all different: #6353 (LUCENE-5289, 2013, fixed: sequential false negative; its fix created this read order), #6143 (LUCENE-5079, introduced the method), #4620 and #4625 (LUCENE-3546/3551, 2011: the same in-transit window in nrtIsCurrent, fixed elsewhere), #7160 and #8550 (open, other symptoms and sites). Refresh 2026-10-30: GitHub search 'hasUncommittedChanges' still returns only #16948, #5316, #6353, #6143; 'hasChangesInRam' 0; IndexWriter items updated since 2026-10-28 are #16867, #16948, #16810, #16950, #16900, #16906, none about this; upstream main 716d6f216f5 unchanged at IndexWriter.java:4226.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with either fix it passes, and with the recommended fix 209 tests in 8 closest suites pass. Release jars 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0 fail; a main() port fails on 4.5.1, 4.6.0, 4.10.4, 5.5.5, 6.0.0, 7.7.3, 8.11.4 and 9.0.0 and passes on 4.5.1 with the operands swapped. Upstream main 2f424fd428da, branch_11x, branch_10_6 and the 10.6.0 tag still have the same line (read, 2026-10-31).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-170 (at 645d4fe14a20) (class javadoc: 'completely thread safe, meaning multiple threads can call any of its methods, concurrently'), :4218-4227 (hasUncommittedChanges javadoc, false-positive cases only, and 'return changeCount.get() != lastCommitChangeCount || hasChangesInRam();'), :4230-4232 (hasChangesInRam), :2908-2925 (checkpoint -> changed -> changeCount.incrementAndGet), :6631-6640 (bufferedUpdatesStream.finished under the writer lock), :6707-6735 (finishApply -> checkpoint when deletes were applied)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:344-357 (at 645d4fe14a20) (anyChanges: numDocsInRAM, delete queue, ticket count, pendingChangesInCurrentFullFlush); DocumentsWriterFlushQueue.java:38-41 ('first inc the ticket count - freeze opens a window for #anyChanges to fail'); IndexUpgrader.java:203 (only non-test caller, an assert)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:272-273, (at 645d4fe14a20) :584-593, :1864-1872, :2173-2179, :2908-2947, :4417-4419, :4440-4452, :6345-6350, :6491-6508, :6562-6567, :6661-6664, :4218-4232, :4403-4413, :6592-6597, :6640, :6721-6724, :6140-6146
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:153-186, (at 645d4fe14a20) :344-357, :376-378; DocumentsWriterDeleteQueue.java:81-82, :181-196; DocumentsWriterFlushQueue.java:38-41, :99-105; BufferedUpdatesStream.java:108-110, :152-167
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-170 (at 645d4fe14a20) (thread safety); IndexWriterConfig.java:102 (DEFAULT_COMMIT_ON_CLOSE = true); IndexUpgrader.java:203 (assert)
Minimal repro
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:81-82; (at 645d4fe14a20) IndexWriter.java:4454-4457; lucene/core/src/test/org/apache/lucene/index/TestDocumentsWriterDeleteQueue.java:139-160
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4218-4224; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:2385-2432
  • lists.apache.org java-user archive, thread 'Presence of uncommitted changes', Michael McCandless's replies of 2014-01-16 and 2014-01-17 (read 2026-10-31); GitHub #6353 comment by Michael McCandless (read 2026-10-31)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.

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.