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.
- The change counter.
IndexWritercounts applied changes inchangeCount, andcommit()remembers the value it committed. A new in-memory segment or an applied delete increments it. - Buffered deletes.
deleteDocumentsrecords 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. - 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.
- Add a document
id:1andcommit(): the last commit holds 1 document. ThendeleteDocuments(new Term("id", "1")). The delete waits in RAM and the change counter does not move, sohasUncommittedChanges()is true only because of its RAM check. - Thread B calls
hasUncommittedChanges(). It first compares the change counter with the last commit's value: equal. Before B looks at RAM, thread A runsflush(), which applies the delete, increments the counter and then empties the buffers. It does not commit. - You expect true: the delete was acknowledged before B's call, no commit ran, and a
commit()now still writes it. - 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.
- 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.
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 method.
IndexWriter.java:4225-4227, no lock:return changeCount.get() != lastCommitChangeCount || hasChangesInRam();.changeCountis anAtomicLong,lastCommitChangeCountavolatile long(:272-273).hasChangesInRam()isdocWriter.anyChanges() || bufferedUpdatesStream.any()(:4230-4232): documents in RAM, the delete queue (read under itsglobalBufferLock), the flush tickets and the full-flush flag (DocumentsWriter.java:344-357,:376-378;DocumentsWriterDeleteQueue.java:181-196), thenbytesUsed != 0of the buffered-updates stream (BufferedUpdatesStream.java:108-110). - 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 printschangeCount4 =lastCommitChangeCount4 after the delete. When buffered deletes use too much heap,DocumentsWriter.applyDeleteOrUpdatefreezes them at once anddeleteDocumentsapplies 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 incrementschangeCountat 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). - A flush moves the delete stage by stage.
flush()runsdoFlush(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,tryApplyreturns and that thread applies it (IndexWriter.java:6491-6500);flush()then waits for all packets (:4417-4419,:4440-4452). Whoever applies it runsforceApply(:6508):updates.apply(segStates)(:6592), thenfinishApply(:6597) callscheckpoint()because a document was deleted (:6721-6724), which incrementschangeCount(:2923-2926); thenbufferedUpdatesStream.finished(updates)(:6640) subtracts the packet's bytes (BufferedUpdatesStream.java:152-167). A delete packet that matches no document is finished with nocheckpoint()(:6721; or it reaches no segment,:6562-6567,:6661-6664); that delete leaves nothing to commit. The NRT pathgetReaderruns the same steps (IndexWriter.java:584-593). - 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. - The method reads the other way round. It reads the last stage, the counter, first. If
checkpoint()runs after that read andfinished()before its stream read, it sees neither. The caller does not have to straddle a whole flush, only the short gap betweencheckpoint()andfinished(), which is why a busy-polling caller can hit it. - The sibling is safe.
nrtIsCurrent(IndexWriter.java:6140-6146) issynchronizedon the writer, likecheckpoint()and thefinished()call, so it cannot fall into this gap.
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 runsflush()orDirectoryReader.open(writer)(both run), oropenIfChanged,SearcherManager.maybeRefreshor a RAM-triggered apply of deletes (the same path by reading, not run); defaultIndexWriterConfig.IndexWriteris 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
falsefor an acknowledged, uncommitted delete. Code that commits only when the method returnstrue(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()orclose()(commitOnClosedefaults 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 beforerollback()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 anassertinIndexUpgrader. - 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).
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.
/*
* 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());
}
}
}
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:
AssertionErroratTestHasUncommittedChangesConcurrentFlush.java:55, with assertions on and off; the next call returns true.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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
# 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)- 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
nrtIsCurrentas 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.testHasUncommittedChangesassertstrueright after a buffereddeleteDocuments(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 ina3c48b5ac41("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 atDocumentsWriterFlushQueue.java:39comes 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?).
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.
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,ecjLintMainandforbiddenApisMainpass.- 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.
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.
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.
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.
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 thathasUncommittedChanges()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).
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.
| Where | When | How | Found |
|---|---|---|---|
| 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-31 | about 100 queries, then a 51-query refresh (1,843 distinct hits) | 0 high; #6353 at rank 2 |
| GitHub, by hand | 2026-10-30 and 2026-10-31 | about 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 files | no report; only #16948 mentions the method, for a false positive at open |
| lists.apache.org | 2026-10-31 | hasUncommittedChanges and uncommitted on dev@, java-user@, issues@, last 30 days | only PR notifications for #16948 and #16643 |
| Closest items (read) | 2026-10-30 | #6353, #6143, #4620, #4625, #5741, #16948, #16900 | none is this bug: a sequential predecessor, the method's origin, precedents in nrtIsCurrent, other sites, or the hunt's own different-root report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal 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 | read | same line (IndexWriter.java:4226; :4271 on branch_10_6) |
| 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 | affected | the 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 | affected | a 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 | read | the 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) |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code-reading lens, TLA+ model, JUnit) | candidate | The 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 | real | Own 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), Low | Re-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-low | Re-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), procedural | It 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 | real | An 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 validated | Gradle 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 fixed | A 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. |
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 491 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,208 words
- 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; amain()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 main2f424fd428da, branch_11x, branch_10_6 and the 10.6.0 tag still have the same line (read, 2026-10-31).
- 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-6146lucene/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-2432lists.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)
- TestHasUncommittedChangesConcurrentFlush.java (one test):
repro/LFV-1060/TestHasUncommittedChangesConcurrentFlush.java(58 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-1060/bug.png(491 KB)
