commit() cuts indexing into before and after. An indexing thread that checked "no commit running" a moment too early flushes an after-the-cut buffer into the commit.
- Buffers. Each indexing thread writes into its own in-memory buffer (a DWPT). Deletes, including the delete half of
updateDocument, go into a shared delete queue. - Full flush.
commit()(and an NRT reopen) switches to a new delete queue, the cut, and writes every buffer of the old queue into the commit. Indexing goes on into buffers of the new queue; those must wait until the commit is done. - Helping flush. After each update, the indexing thread looks for a buffer the RAM policy marked for flushing and, if no full flush is running, flushes it itself.
fullFlush = false, the commit starts its full flush, another updater marks a new-queue buffer pending, and the first updater flushes it inside the commit. Select the still to open it at full size. Generated with AI, checked against the code.- 16 threads replace documents by key with
updateDocument, each on its own 50 keys (800 keys), while the test thread loopscommit()and counts every key in each commit point. Default codec, 0.25 MB RAM buffer, JVM pinned to 2 CPUs. - You expect at most one live document per key in every commit:
updateDocument's delete and add are atomic for readers, and a commit holds exactly the operations up to the sequence numbercommit()returns. - Instead commit 106 holds two live documents for key 388 (after 15.6 s, assertions off; 6 of 6 runs fail). With assertions on, it fails too: with the same duplicate, or with an assertion in
DocumentsWriter.doFlushthat closes the writer. - Why: after an update, an indexing thread asks "is a commit running?" under a lock, releases the lock, then picks a buffer marked for flushing and flushes it. If
commit()starts in that gap, the thread flushes a buffer that belongs after the commit inside the commit. - So the commit gets operations newer than its sequence number, and the newer update's delete can miss the older copy. One re-check under the lock fixes it: all 5,000 commits are clean.
Applications that call updateDocument from several threads on one IndexWriter while another thread commits (by code reading also softUpdateDocument and NRT reopens, not run), on the default code path. Rare: reliable only when indexing threads outnumber CPUs (16 on 2 or 4); not seen unpinned on 32 cores in 120 s, nor shown at the default 16 MB buffer.
Mechanism, terms and numbers745 words
Several threads replace documents by key with IndexWriter.updateDocument while another thread calls commit(). DocumentsWriterFlushControl.nextPendingFlush checks "is a full flush running?" under its lock, releases the lock, and only then checks out a flush-pending in-memory buffer (DWPT) without checking again. If the commit's full flush starts in that gap, the indexing thread flushes a buffer of the new, post-commit delete queue inside the commit. The commit point then holds operations above the sequence number commit() returned and, when the newer update's delete misses the older document, two live documents for one key; the duplicate stays until the key is updated again, and a crash after that commit would recover it. With assertions on, the same race shows either as the duplicate or as a tripped doFlush assertion that closes the writer. One public-API test reproduces it on main when the indexing threads outnumber the CPUs; the code dates from LUCENE-3090 (2011), and 10.6.0 and 10 of the 14 releases run from 5.3.0 to 10.5.2 fail too. A one-hunk re-check of fullFlush under the lock fixes it.
- Indexing threads help flush. After every update
DocumentsWriter.postUpdatecallsmaybeFlush(DocumentsWriter.java:400-410, with the defaultcheckPendingFlushOnUpdate), which callsflushControl.nextPendingFlush()(:493-500). - The stale check.
nextPendingFlush(DocumentsWriterFlushControl.java:441-469) copiesfullFlushandnumPendinginto locals under the monitor (:450-451) and releases it (:452). Thenif (numPending > 0 && fullFlush == false) { // don't check if we are doing a full flush(:453) scans the pool and returnscheckOutForFlush(next)for the first flush-pending DWPT it can lock (:454-459). - No second look.
checkOutForFlush(:409-424) issynchronizedbut never readsfullFlush. - The full flush starts in the gap.
commit()reachesDocumentsWriter.flushAllThreads(DocumentsWriter.java:667-715), whosemarkForFullFlushsetsfullFlush = trueand swaps the delete queue under the monitor (DocumentsWriterFlushControl.java:564,:575), then collects the old queue's DWPTs (:580-606). - A new-queue DWPT can already be pending. Indexing continues during the commit, and the RAM policy marks the largest non-pending DWPT pending, possibly another thread's idle one (
FlushByRamOrCountsPolicy.java:83-90). - The sibling path does it right.
checkout()readsfullFlushunder the monitor and parks such a DWPT inblockedFlushesuntilfinishFullFlush(DocumentsWriterFlushControl.java:253-272,:397-407,:649-664). - Result. The stale thread flushes a new-queue DWPT inside the full flush;
flushAllThreadswaits for it (DocumentsWriter.java:690) and the commit publishes its segment and its frozen new-queue deletes.DocumentsWriter.java:508-515asserts that only the full flush's queue flushes during it. That assertion is armed only aftermarkForFullFlushreturns (DocumentsWriter.java:680-681), so it catches an out-of-turn flush that starts after the collection but not one that starts during it; with-eathe test shows either the assertion or the duplicate.
In the TLA+ model's counterexample, the out-of-turn flush starts while markForFullFlush is still collecting, so its flush ticket is queued ahead of the old queue's tickets. Its delete packet is then published before old-queue segments that flush after it, and a packet applies only to segments published before it, so the newer update's delete never reaches the older copy (FrozenBufferedUpdates.java:460-462). By code reading this also needs the older version's own delete to have been frozen already, by an old-queue flush whose ticket is ahead of the out-of-turn one: every flush freezes its queue's whole delete buffer (DocumentsWriterPerThread.java:429), and an old-queue packet published after the new segment would delete the newer version instead. The model does not model deletes, so it cannot settle this; the executed duplicates are consistent with it. Neither ordering was instrumented at runtime, and one step of the cited trace (state 23) is not a code schedule: a thread marks another buffer pending while its own is pending, which doAfterDocument does not do (DocumentsWriterFlushControl.java:232-238). Observed directly: an observation-only probe tied every failing commit to a new-queue check-out by nextPendingFlush during that commit's full flush (or the one before), and the -ea stack goes through postUpdate -> maybeFlush -> doFlush.
- The hunt's longer test also checks the sequence-number cut: "commit #88 returned seqNo=547238 but holds a live document of thread 4 with seqNo=547246".
- The same workload with the opposite oracle found 18 of 4,746 commits with keys that had no live document (298 keys: a later update's delete visible without its add); 0 of 4,499 with the fix. Not claimed separately.
Exposure and likelihood264 words
- Trigger. Concurrent
updateDocumenton one writer whilecommit()runs (softUpdateDocumenttakes the sameupdateDocumentspath by code reading,IndexWriter.java:1938-1947; not run); an indexing thread descheduled inside a two-statement window ofnextPendingFlushjust as the full flush starts; a buffer of the new delete queue already marked pending by the RAM policy. Default flush-by-RAM policy and codec; anyDirectoryby code reading (every run usedByteBuffersDirectory); no opt-in feature or expert API.IndexWriteris documented as "completely thread safe". - Effect. Silent wrong results: the commit point holds two live documents for one key until the key is updated again, and a crash after that commit would recover the duplicate (inferred from commit semantics; no crash was simulated); the commit also holds operations above the sequence number
commit()returned. With-ea(tests), it can instead show as a tragicAssertionErrorthat closes the writer. - How often. With 16 indexing threads pinned to 2 or 4 CPUs every 30 s run failed (10 of 10, 5 of 5); unpinned on 32 cores no run failed in 120 s. The repro lowers the RAM buffer to 0.25 MB and uses
NoMergePolicyonly to raise the flush and commit rate; an independent test failed with the default merge policy and scheduler at 1 MB. No failure has been shown at the default 16 MB buffer, and no field report is known. CPU-oversubscribed indexing with frequent commits raises the rate. - Not exercised. NRT reopen runs the same full flush and
softUpdateDocumentthe same update path by code reading, but onlycommit(),updateDocumentandByteBuffersDirectorywere run.
One JUnit test on the public API: 16 threads call only updateDocument, the test thread loops commit() and checks that each of the 800 keys has at most one live document. It is a race test with a fixed seed; run it pinned to 2 CPUs (taskset -c 0,1) so the indexing threads outnumber the CPUs.
/*
* 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.AlreadyClosedException;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.InfoStream;
public class TestUpdateDocumentConcurrentCommit extends LuceneTestCase {
// Expected: every commit holds at most one live document per key; actual: some key has two.
public void testAtMostOneLiveDocumentPerKey() throws Exception {
int numThreads = 16, keysPerThread = 50;
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w =
new IndexWriter(
dir,
new IndexWriterConfig()
.setCodec(TestUtil.getDefaultCodec())
.setInfoStream(InfoStream.NO_OUTPUT)
.setRAMBufferSizeMB(0.25)
.setMergePolicy(NoMergePolicy.INSTANCE))) {
AtomicBoolean stop = new AtomicBoolean();
Thread[] threads = new Thread[numThreads];
for (int t = 0; t < numThreads; t++) {
int firstKey = t * keysPerThread;
byte[] pad = new byte[2000 + 300 * t];
threads[t] =
new Thread(
() -> {
try {
for (int i = 0; stop.get() == false; i++) {
String id = Integer.toString(firstKey + i % keysPerThread);
Document doc = new Document();
doc.add(new StringField("id", id, Field.Store.NO));
doc.add(new StoredField("pad", pad));
w.updateDocument(new Term("id", id), doc);
}
} catch (AlreadyClosedException e) {
return;
} catch (Exception e) {
throw new RuntimeException(e);
}
});
threads[t].start();
}
try {
for (int c = 0; c < 5000; c++) {
w.commit();
try (DirectoryReader r = DirectoryReader.open(dir)) {
IndexSearcher s = new IndexSearcher(r);
for (int k = 0; k < numThreads * keysPerThread; k++) {
assertTrue("commit " + c + ", key " + k,
s.count(new TermQuery(new Term("id", Integer.toString(k)))) <= 1);
}
}
}
} finally {
stop.set(true);
for (Thread t : threads) {
t.join();
}
}
}
}
}
cd $LUCENE && taskset -c 0,1 ./gradlew --no-daemon :lucene:core:test --tests TestUpdateDocumentConcurrentCommit -Ptests.seed=DEADBEEF -Ptests.asserts=false
- Expected
- Every commit point holds at most one live document per key (800 keys, up to 5,000 commits).
- Observed
- Two live documents for one key, e.g.
AssertionError: commit 106, key 388(assertions off); with assertions on, either the same duplicate orAssertionError: expected: DWDQ: [ generation: 811 ] but was: ... 812inDocumentsWriter.doFlush, which closes the writer.
More files, environment, full output592 words
The test pins what its writer uses (the default codec, InfoStream.NO_OUTPUT, ByteBuffersDirectory, a plain IndexWriterConfig); the fixed seed fixes the suite-level settings the framework still randomises (locale, time zone, similarity, the global test codec). It stops after at most 5,000 commits. The 0.25 MB RAM buffer and NoMergePolicy only raise the flush and commit rate. No public-API hook exists inside the two-statement window, so the test is reliable only with more runnable indexing threads than CPUs: pinned to 2 CPUs it failed 6 of 6 times with assertions off and 4 of 4 with assertions on (an independent fact check: -ea 1 of 2 standalone runs and its gradle run); unpinned on 32 cores a similar test did not fail in 120 s.
Use --no-daemon with taskset: Gradle then forks a fresh single-use daemon under the pin and the test JVM inherits it, which a daemon started earlier outside the pin would not. In these local, non-CI runs the Lucene test plugin added -XX:TieredStopAtLevel=1 -XX:+UseParallelGC -XX:ActiveProcessorCount=1 (TEST_JVM_ARGS replaces them), so the test JVM reported cpus=1 under the 2-CPU pin; the test still failed through gradle. Run ./gradlew tidy before putting the test in a PR.
The hunt's earlier, longer test (supplementary, not needed to file) also checks that no live document's updateDocument sequence number is above the one commit() returned.
Regression test: this same test; it passes with the recommended fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the one-hunk fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64, pinned to 2 CPUs with taskset (the only platform run; the code path is pure Java) |
cp $REPRO/TestUpdateDocumentConcurrentCommit.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && taskset -c 0,1 ./gradlew --no-daemon :lucene:core:test --tests TestUpdateDocumentConcurrentCommit -Ptests.seed=DEADBEEF -Ptests.asserts=false
# assertions on: drop -Ptests.asserts=false (the doFlush assertion closes the writer)
# standalone runner, main 645d4fe14a20, taskset 2 CPUs, seed DEADBEEF, assertions off (15.6 s)
testAtMostOneLiveDocumentPerKey FAILED
java.lang.AssertionError: commit 106, key 388
at org.apache.lucene.index.TestUpdateDocumentConcurrentCommit.testAtMostOneLiveDocumentPerKey(TestUpdateDocumentConcurrentCommit.java:77)
# 5 more iterations, assertions off: 5 of 5 fail (commits 123, 136, 367, 429, 826)
# assertions on (88 s): the commit thread's exception, with the indexing thread's AssertionError as its cause
java.lang.IllegalStateException: this writer hit an unrecoverable error; cannot commit
org.apache.lucene.index.IndexWriter.startCommit(IndexWriter.java:5919)
org.apache.lucene.index.IndexWriter.prepareCommitInternal(IndexWriter.java:3915)
org.apache.lucene.index.IndexWriter.commitInternal(IndexWriter.java:4253)
org.apache.lucene.index.IndexWriter.commit(IndexWriter.java:4215)
Caused by: java.lang.AssertionError: expected: DWDQ: [ generation: 811 ] but was: DWDQ: [ generation: 812 ] true
org.apache.lucene.index.DocumentsWriter.doFlush(DocumentsWriter.java:515)
org.apache.lucene.index.DocumentsWriter.maybeFlush(DocumentsWriter.java:496)
org.apache.lucene.index.DocumentsWriter.postUpdate(DocumentsWriter.java:407)
org.apache.lucene.index.DocumentsWriter.updateDocuments(DocumentsWriter.java:450)
# 3 more iterations, assertions on: 3 of 3 fail (2 duplicates at commits 1235 and 1257, 1 assertion)
# gradle, scratch worktree of 645d4fe14a20, taskset 2 CPUs, -Ptests.seed=DEADBEEF, assertions on (test 355 s)
java.lang.AssertionError: expected: DWDQ: [ generation: 1259 ] but was: DWDQ: [ generation: 1260 ] true
at org.apache.lucene.index.DocumentsWriter.doFlush(DocumentsWriter.java:515)
... maybeFlush(DocumentsWriter.java:496) <- postUpdate(DocumentsWriter.java:407)
(then, while the writer rolls back: AssertionError: pendingNumDocs 12635 != 11985 totalMaxDoc)
:lucene:core:test (FAILURE): 1 test, 1 failure
# gradle, the same with -Ptests.asserts=false (test 122 s)
java.lang.AssertionError: commit 646, key 262
at org.apache.lucene.index.TestUpdateDocumentConcurrentCommit.testAtMostOneLiveDocumentPerKey(TestUpdateDocumentConcurrentCommit.java:77)
:lucene:core:test (FAILURE): 1 test, 1 failure
# with the fix, gradle, same worktree and pin: :lucene:core:test (SUCCESS): 1 test, assertions off (882 s) and on (1,267 s)
# with the fix, standalone runner: all 5,000 commits clean, assertions off (329 s) and on (405 s)
# independent fact check (2026-10-30), its own scratch worktree of 645d4fe14a20, same commands, 2 pinned CPUs
# gradle -Ptests.asserts=false: AssertionError: commit 50, key 6 (about 18 s); with the fix: SUCCESS (1,076 s)
# gradle, assertions on: the same doFlush:515 AssertionError (225 s)
# standalone: -da commit 328, key 588; -ea 1 of 2 runs failed (the assertion); with the fix -da and -ea passupdateDocumentpromises its delete and add are "atomic as seen by a reader";commit()holds "all sequence numbers <= this value" and no others.- The code agrees: "don't check if we are doing a full flush", and
doFlushasserts that no such flush happens. - Upstream fixed bugs of the same family at other sites (LUCENE-3348, LUCENE-8317, #13627).
The full argument337 words
- Contracts. "IndexWriter instances are completely thread safe, meaning multiple threads can call any of its methods, concurrently" (
IndexWriter.java:167-168).updateDocument: "The delete and then add are atomic as seen by a reader on the same index (flush may happen only after the add)" (:1902-1904).commit(): "All sequence numbers <= this value will be reflected in the commit, and all others will not" (:4208-4210; the same onprepareCommit,:3730-3732). - The code's intent. The comment at the check, "don't check if we are doing a full flush" (
DocumentsWriterFlushControl.java:453);checkout()parks a pending DWPT during a full flush (:253-272);numBlockedFlushesdescribes a DWPT that "needs flushing but must not be flushed until the full flush has finished" (:722-726);doFlushasserts that only the full flush's queue flushes during it (DocumentsWriter.java:508-515), andassertTicketQueueModificationsays "only modifications from the current flushing queue are permitted while doing a full flush" (:654-658).prepareCommitInternalclones the segment infos "so that no partial changes (eg a delete w/o corresponding add from an updateDocument) can sneak into the commit point" (IndexWriter.java:3845-3849). The 4.0 CHANGES entry: flushes that occur while a full flush is running "are queued and will happen after all DWPT involved in the full flush are done flushing" (lucene/CHANGES.txt:15736-15741). - Tests.
TestIndexingSequenceNumbers.testStressConcurrentCommit(@Nightly) checks the same contract with concurrent updates and commits ("this commit point should reflect all operations <= this seqNo");TestIndexWriter.testNeverCheckOutOnFullFlushasserts that nothing is checked out once a full flush has started throughcheckoutLargestNonPendingWriter. No test targets thenextPendingFlushwindow; concurrent stress tests such as the@NightlytestStressConcurrentCommitcan reach it only by chance (nextPendingFlushruns after every update,DocumentsWriter.java:406-407,:493-494). - History. Bugs of the same family, from other root causes, were fixed as bugs: LUCENE-3348 (#4421, 2011), LUCENE-3368 (#4441), LUCENE-8317 (#9364, 2018), LUCENE-8639 (#9685), LUCENE-8813 (#9857, 2019; seen as the sibling
assertTicketQueueModificationassertion) and #13127/#13571 (seen asmaxSeqNoassertions), fixed by #13627 (2024).
The devil's-advocate check152 words
The strongest case: DocumentsWriterFlushControl is internal, and the unlocked scan deliberately keeps the flush-control lock off the hot indexing path (contention work such as #12198 and #12958 shows the concern); commit() does not block indexing, so an update racing the cut may land on either side, and the sequence-number finding could be called a linearization question.
Why it fails: the duplicate does not depend on sequence numbers at all, since a reader sees an update's add without its delete, which updateDocument promises cannot happen; the comment, the sibling path, the javadoc and the assertion all say a new-queue DWPT must not flush during a full flush; and the fix keeps the unlocked scan, adding one re-check where the lock is taken anyway (checkOutForFlush is synchronized). An optimisation can justify the unlocked read, not a lost delete. Verdict: not intended, a defect. Cross-model reviewers (OpenAI GPT-5.6) rated the works-as-designed case weak.
Recommended Re-check fullFlush under the lock before the check-out (validated)
In nextPendingFlush, re-check fullFlush under the flush-control lock right before checkOutForFlush, and skip the buffer when a full flush has started; it stays pending and is flushed after the commit (+7/-1 lines). The test then passes all 5,000 commits, and the closest index tests pass.
if (perThreadPool.isRegistered(next)) {
synchronized (this) {
// fullFlush was read before the scan: a full flush may have started since, and a
// pending DWPT of its new delete queue must wait until that full flush is done
if (this.fullFlush == false) {
return checkOutForFlush(next);
}
}
}All options and trade-offs3 options ยท 641 words
Option A was built and run; B and C are untested alternatives.
In the pool scan of DocumentsWriterFlushControl.nextPendingFlush, wrap the check-out in synchronized (this) and call checkOutForFlush only while fullFlush is still false. A skipped DWPT stays flush-pending in the pool; once finishFullFlush clears fullFlush, the next nextPendingFlush (or the next commit's markForFullFlush) flushes it.
if (perThreadPool.isRegistered(next)) {
synchronized (this) {
// fullFlush was read before the scan: a full flush may have started since, and a
// pending DWPT of its new delete queue must wait until that full flush is done
if (this.fullFlush == false) {
return checkOutForFlush(next);
}
}
}Trade-offs
- Lock order DWPT -> flush control is the existing one (
checkOutForFlushalready runs under the DWPT lock and issynchronized); the unlocked scan stays, so indexing threads take the lock no more often than before. - A pending new-queue DWPT now waits for the commit to finish before it flushes, which is what the sibling
checkout()path and the 4.0 CHANGES entry already describe. - The hunt's first version of the comment said
fullFlush"was read without the lock held"; it is read under the lock, which is then released. The diff here rewords the comment; the executable change is the same.
What the checks validated
- This doc's runs: scratch worktree of
645d4fe14a20with the diff, gradle with the plugin's local test JVM flags, pinned to 2 CPUs, seed DEADBEEF. The minimal test passes all 5,000 commits with assertions off (882 s) and on (1,267 s); on the same worktree before the fix it failed at commit 646 (assertions off, 122 s) and on thedoFlush:515assertion (assertions on, 355 s). - Unpinned, with the fix:
:lucene:core:testforTestIndexWriter*,TestDocumentsWriter*,TestFlushByRamOrCountsPolicy,TestIndexingSequenceNumbers,TestStressNRT,TestStressIndexing,TestStressIndexing2andTestNRTThreads, assertions on: 30 suites, 371 tests, 28 skipped, 0 failures (withouttests.nightly,TestNRTThreads,TestStressIndexingand the@NightlytestStressConcurrentCommitwere among the skipped). :lucene:core:checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass with the fix.- Independent fact check: the same gradle command with the fix passes (1,076 s, assertions off); standalone with the fix,
-da(361 s) and-ea(450 s) pass all 5,000 commits. - Earlier (hunt, standalone runner with the same hunk): the minimal test passes all 5,000 commits with
-da(329 s) and-ea(405 s); the hunt's longer test 10 of 10 x 30 s (-da) and 5 of 5 x 120 s (-ea) in matched runs against 10 of 10 and 5 of 5 failures on stock; 38 tests inTestIndexWriterThreadsToSegments,TestFlushByRamOrCountsPolicy,TestIndexWriterDelete,TestStressNRT,TestDocumentsWriterPerThreadPoolandTestDocumentsWriterDeleteQueueran with 0 failures (the invocation also listed the nightly-onlyTestNRTThreads, which ran nothing, so the harness labelled itno_tests); nightly,TestNRTThreadsandTestIndexWriterDelete(26 tests) pass. - TLA+ model with the fix: no violation in 49.5M distinct states to depth 38 (not exhaustive).
- Not run: the full Lucene suite, benchmarks. The race runs and the bounded model check support the fix; they do not prove it complete.
On the re-check, when fullFlush is true, call checkoutAndBlock(next) so the DWPT goes to blockedFlushes, as the sibling checkout() does (DocumentsWriterFlushControl.java:256-259).
Trade-offs
Mirrors the sibling path, but must handle a pending DWPT of the old queue found while markForFullFlush is still collecting: it would leave the pool before markForFullFlush sees it and reaches the flush queue only if pruneBlockedQueue(flushingQueue) (:612) runs after the block. Not built.
Run the pool scan of nextPendingFlush inside its synchronized block.
Trade-offs
Simplest to reason about, but puts the flush-control lock on the indexing path whenever buffers are pending, the contention the unlocked scan avoids (#12198, #12958). Not built or benchmarked.
Applications that rely on updateDocument to replace or remove content can keep serving the old version from that commit until the key is updated again.
A TLA+ model of IndexWriter's flush protocol kept nextPendingFlush's check and check-out as two steps; the model checker found a schedule that breaks the commit, and none with the step made atomic. A public-API JUnit then reproduced it on the stock jars, and the one-line re-check made it pass.
The method in full179 words
Method: TLA+ model checking (TLC) of the flush protocol, then an executed public-API test (the hunt classes it as semi-formal: the model gave the schedule, the stock-jar test is the proof).
- Model (2026-10-27). A code-reading hypothesis, formed while building a TLA+ model of the full flush (
FnbFlush.tla), keptnextPendingFlush'sfullFlushread and its pool scan as two steps, as in the code. TLC violatedNoBadat depth 27 andTicketOrderat depth 30 (the new-queue ticket queued ahead of the old-queue tickets); the same model with the step atomic is exhaustively clean (13,954,054 distinct states). That unit could not reproduce it on the stock jar and judged it proof-only. - Execution (2026-10-29). A second unit wrote a public-API JUnit (16
updateDocumentthreads, acommit()loop, oracles: one live document per key and the commit's sequence-number cut) and ran it on the stock main jars pinned to 2 CPUs: it failed with assertions off and on, and passed with the one-hunk fix in matched runs. The minimal repro above is that test cut down to the duplicate-key oracle.
None found: no issue, PR, list thread or downstream report names this window. Bugs of the same family were reported and fixed at other sites (LUCENE-3348, LUCENE-8317, LUCENE-8813, #13627 and others), and the test fails on code with all of them. Searched 2026-10-29.
Searches5 searches
- Same family, other root causes, all fixed: #4421 (LUCENE-3348), #4441 (LUCENE-3368), #9364 (LUCENE-8317), #9685 (LUCENE-8639), #9857 (LUCENE-8813), #13127/#13571 fixed by #13627. None touches
nextPendingFlush. - Context, not reports: #4163 (LUCENE-3090, the introducing change), #10344 (LUCENE-9304), open #11126 (LUCENE-10088, where maintainers describe pending DWPTs as blocked during a full flush).
- Open PR #16867 touches
DocumentsWriterFlushControl(a delete-bytes stall) but not this window. Not fixed on upstream main716d6f216f52. Tracking issue #16937 does not list it. - Limits: keyword search; GitHub search reads the first 30-50 results per query and does not index PR diffs (open PRs on these files were checked by their diffs).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-29 | 16 queries plus about 30 hand searches: nextPendingFlush, checkOutForFlush, "expected: DWDQ", duplicate after updateDocument, commit seqNo cut, TestStressNRT/TestNRTThreads failures; every item opened since 2026-10-26 | nextPendingFlush 0 hits, "expected: DWDQ" 0 hits; only same-symptom bugs fixed elsewhere |
| Open PRs touching DocumentsWriter*, FlushByRamOrCountsPolicy, FlushPolicy, IndexWriter.java | 2026-10-29 | diffs read (9 + 2 PRs) | none changes nextPendingFlush or checkOutForFlush |
| Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive | 2026-10-29 | 20 list and 4 JIRA queries | no report |
| Elasticsearch, OpenSearch, Solr; web | 2026-10-29 | 2 downstream code and 6 web queries | no report of this mechanism |
| GitHub refresh | 2026-10-29 | 6 issue and PR searches; the items created that day; open PR #16867; upstream main file history | nothing new; not fixed |
Introduced by LUCENE-3090 (2011), which moved the full-flush check out of the synchronized check-out; kept by LUCENE-3773 (2012) and LUCENE-9304 (2020). Fails on main, 10.6.0 and 10 of the 14 releases run from 5.3.0 to 10.5.2, each passing with its own one-hunk fix (9.6.0 not fix-tested); 8.6.0, 8.11.4, 9.0.0 and 9.3.0 did not hit.
Versions tested7 rows
Introduced by LUCENE-3090 (#4163, 4fd5dceb998, svn r1104026, 2011-05-17; svn/JIRA era, no pull request). Before it, the synchronized tryCheckoutForFlush returned null while fullFlush was set, so the check and the check-out were one step; the change moved the check into nextPendingFlush, outside the monitor. This rests on reading the diff: a 2011 commit-level bracket was built and run but cannot attribute it, because both builds lose deletes within 2 s for other, later-fixed reasons (e.g. LUCENE-3348).
Kept open by LUCENE-3773 (d5056929d02, 2012: the read moved under the monitor into a local, the check-out still after the release) and LUCENE-9304 (2602269f3ec, 2020: the DWPT pool rewrite; checkOutForFlush never looks at fullFlush).
The minimal test itself fails on the released 10.6.0, 10.5.2 and 9.12.3 jars (independent fact check); on 9.1.0 it compiles but did not hit in 400 s. The history runs used the hunt's longer test on main, 10.5.2 and 9.12.3 and ports of it with the same workload on the other releases (the longer test needs 9.5+ APIs). "Not reproduced" is not "unaffected": the window's code is the same in every release since 4.0.0-ALPHA.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails, assertions off and on; passes with the fix |
10.6.0 (released 2026-10-29 from 4ae946e5a9b, the RC1 commit) | affected | minimal test fails on the released jars (commit 209, key 297); on the RC1 core jar a port fails (seqNo cut at commit #10, duplicate at #3896) and passes with the fix |
| 10.5.2, 9.12.3 | affected | minimal test fails on the release jars; the hunt's longer test and a port fail; the port passes with the fix |
| 9.1.0, 9.5.0, 9.6.0 | affected | port fails; 9.1.0 and 9.5.0 pass with the fix (9.6.0: no fix run); the minimal test did not hit on 9.1.0 in 400 s |
| 5.3.0, 5.5.5, 6.6.6, 7.7.3, 8.5.2 | affected | port fails (duplicate key); passes with the fix (weakly for 5.3.0: 81 commits) |
| 4.0.0, 4.10.4, 5.2.1, 8.6.0, 8.11.4, 9.0.0, 9.3.0 | not reproduced | runs of 60-600 s did not hit; same code by reading |
| 3.6.2 | not affected | passes, assertions off and on (no DWPT; earlier 3.x releases predate DWPT, by code) |
Reproduced by an independent reproducer, a devil's-advocate verifier, an adjudication and cross-model reviewers (OpenAI GPT-5.6), each with its own test or a re-run on stock main; the fix passed every time.
Checks and verdicts330 words
Each check ran its own test or re-ran an earlier one on stock main; the controls (one updater, the fix) pass.
| Check | Verdict | What it found |
|---|---|---|
| TLA+ model (TLC) | proof-only | NoBad violated at depth 27, TicketOrder at depth 30; atomic step exhaustively clean (13.95M states). Deletes are not modelled, and one trace step (state 23) is not a code schedule. |
| Finder | candidate | Longer test: -da 10 of 10 x 30 s (8 with duplicates), also on 4 CPUs; -ea 5 of 5; the fix 10/10 and 5/5 pass. |
| Cross-model review, refute lens (OpenAI GPT-5.6) | real (high) | Own test committed a duplicate in 3.7 s on 2 CPUs; a one-updater control passed; the fix passed. |
| Independent reproducer | real | Own test (1 MB buffer, default merge policy) fails on main 3 of 4, on 10.5.2 and 9.12.3; fix 0 of 4; a probe ties failing commits to new-queue check-outs. |
| Works-as-designed lens (OpenAI GPT-5.6) | real (high) | Own test committed two live documents for one id; the intended-behaviour case is weak; the fix passed. |
| Intended-behaviour devil's advocate | real (high) | No intended reading survives; -ea hit the doFlush:515 assertion via postUpdate -> maybeFlush after 9 s. |
| Adjudication | confirmed (high) | Re-ran: main -da fails (cut violation, two live documents for key 3-34); the fix passes 90 s. |
| Re-run and minimal repro | still fails (2026-10-29) | Minimal test: -da 6 of 6 fail with a duplicate, -ea 4 of 4 fail; the fix passes all 5,000 commits both ways. |
| Independent fact check | real (2026-10-30) | The doc's gradle command fails on its own scratch worktree (-da commit 50; -ea doFlush:515) and passes with the fix; standalone -da fails, -ea 1 of 2; the minimal test fails on the 10.6.0, 10.5.2 and 9.12.3 release jars. |
| This doc's gradle runs | still fails (2026-10-30) | Scratch worktree of 645d4fe14a20: stock fails through gradle; with the fix the test and the closest index suites pass. |
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 596 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,497 words
- Registry title
- updateDocument racing commit() can leave two live documents for one key (a lost delete) and operations above the sequence number commit() returned in the commit point, because DocumentsWriterFlushControl.nextPendingFlush flushes a DWPT of the new delete queue during the running full flush
- Area
- index / IndexWriter full flush (DocumentsWriterFlushControl.nextPendingFlush)
- Priority
- Medium, rank 13: A silent, durable lost delete and broken commit cut in core updateDocument/softUpdateDocument on the default path of every release since 5.3 puts it high in Medium, but.
- Impact class
- silent wrong results
- Impact note
- Applications that call updateDocument (or softUpdateDocument) from several threads on one IndexWriter while another thread calls commit(), on the default code path (flush by RAM, default codec and directory, no opt-in feature) of every release checked from 5.3.0 to 10.6.0 and main, can get a commit point that holds two live documents for one key (the older one stays until the key is updated again, and a crash after that commit recovers it) and operations above the sequence number commit() returned; it is silent with assertions off; it shows within seconds when indexing threads outnumber CPUs, did not show in 120 s unpinned on 32 cores, and has not been shown at the default 16 MB RAM buffer.
- Summary
- Several threads replace documents by key with updateDocument while another thread calls commit(); you expect every commit point to hold exactly one live document per key and only the operations up to the sequence number commit() returned. Instead, an indexing thread that has just seen 'no full flush running' in nextPendingFlush can, if the commit's full flush starts at that moment, flush a buffer that belongs after the commit inside it, so the commit contains newer operations and, when the delete half of an update misses the older document, two live documents for one key. With assertions enabled it often shows instead as a tragic AssertionError in DocumentsWriter.doFlush that closes the writer.
- Introduced
- 4fd5dceb998bea7e2d3da96bcd24fce43ed2014c (LUCENE-3090, apache/lucene#4163 'DWFlushControl does not take active DWPT out of the loop on fullFlush', svn r1104026, author s1monw, reviewed by mikemccand; no pull request, svn/JIRA era) [read: it removed 'if (fullFlush) return null;' from the synchronized tryCheckoutForFlush and put the fullFlush check into nextPendingFlush outside the monitor, so the check and the check-out stopped being one atomic step; git log -S on the check's comment finds only this commit. A commit-level bracket was built and run (4fd5dceb998 and its parent a320a7929aa) but is not attributable: both lose deletes within 2 s because the May 2011 trunk had other, later fixed bugs with the same symptom, e.g. LUCENE-3348]. Kept open by LUCENE-3773 (d5056929d02, 2012-02-12: fullFlush is read under the monitor into a local, the check-out still runs after the release) and LUCENE-9304 (2602269f3ec on main, 83b34d47900 on branch_8x, 2020-04-11: DWPT pool rewrite; checkOutForFlush never looks at fullFlush) (2011-05-17 (author and commit date of 4fd5dceb998, 06:58:39 UTC))
- First affected version
- 4.0.0-ALPHA by code [read] (git tag --contains 4fd5dceb998: 4.0.0-alpha first; no 3.x release has DocumentsWriterPerThread). First release with an executed failure: 5.3.0
- Affected versions
- [exec] main 645d4fe14a20 (-da and -ea: the repro, the independent reproduction, the cross-model review, the intended-behaviour check, the adjudication and the re-run), 10.6.0 RC1 4ae946e5a9b (core jar, a port with both oracles: fails; passes with the fix), 10.5.2, 9.12.3, 9.6.0, 9.5.0, 9.1.0, 8.5.2, 7.7.3, 6.6.6, 5.5.5 and 5.3.0 fail (-da; every one of them except 9.6.0 passes with only that release's one-hunk fix shadowed). [read] 4.0.0-ALPHA to 5.2.1, 8.6.0 to 9.0.x, 9.3.0 and 10.0.0 to 10.5.1 have the same window; runs of 60-600 s on 4.0.0, 4.10.4, 5.2.1, 8.6.0, 8.11.4, 9.0.0 and 9.3.0 did not hit (not reproduced, not shown unaffected); upstream main e1faeba08bd changes DocumentsWriterFlushControl.java since 645d4fe only by #16867, outside nextPendingFlush, and the minimal repro fails there with -da and -ea [exec 2026-11-02, 2 pinned CPUs]. Not affected [exec]: 3.6.2 and earlier (no DocumentsWriterPerThread; pass with -da and -ea)
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: code reading; also reached by TLA+/TLC, stress
- Upstream and prior reports
- none found (2026-10-29). The prior-report record (verdict new): a scripted prior-report search with 51 queries (GitHub issues and PRs 16, commits 3, downstream 2, mailing lists 20 over dev@, java-user@, issues@ and commits@, JIRA archive 4, web 6; 231 distinct hits, 0 high, 22 medium) plus manual GitHub searches (nextPendingFlush: 0 hits; checkOutForFlush; 'expected: DWDQ'; updateDocument duplicate concurrent commit; TestStressNRT failure; ...), every apache/lucene item created since 2026-10-26, the diffs of the 9 open PRs that touch DocumentsWriter*, FlushByRamOrCountsPolicy or FlushPolicy and of 2 that touch IndexWriter.java, commits on main and branch_10_6 since 2026-05-01 by author date, Elasticsearch, OpenSearch and Solr, and the JIRA archive. Refresh (2026-10-29): six GitHub issue and PR searches (nextPendingFlush 0 hits; checkOutForFlush only LUCENE-2573 from 2011; DocumentsWriterFlushControl only older or unrelated items, among them open #14261, a nightly OutOfMemoryError, and open #15152, a ByteBlockPool overflow), the 12 apache/lucene items created on 2026-10-29 (none related), #16867 (open at that check, last commit; merged unchanged 2026-10-30 as 2813efa40a3 on main and cdfe8aa7829 on branch_11x, not on branch_10_6; its diff adds a delete-bytes stall and does not touch nextPendingFlush or checkOutForFlush), and no commit to DocumentsWriterFlushControl.java on upstream main (head 716d6f216f52) since 2026-10-01 (through main e1faeba08bd the only later commit is #16867's 2813efa40a3, which does not touch nextPendingFlush). Same symptom, other roots, all fixed: #4421 (LUCENE-3348, 2011), #4441 (LUCENE-3368, 3.x), #9364 (LUCENE-8317, 2018), #9685 (LUCENE-8639, 2019), #9857 (LUCENE-8813, 2019), #13127 and #13571 fixed by #13627 (2024). Design intent, not reports: #4163 (LUCENE-3090, the introducing change), #10344 (LUCENE-9304), open #11126 (LUCENE-10088, where maintainers describe that pending DWPTs are blocked during a full flush). Not fixed upstream. Tracking issue #16937 does not list it
- Lucene commit tested
- main
645d4fe14a20(2026-10-28; upstream main716d6f216f52has no change toDocumentsWriterFlushControl.javasince): the minimal test fails with assertions off and on, through a standalone runner and through gradle in a scratch worktree, JVM pinned to 2 CPUs; with the one-hunk fix it passes. An independent fact check repeated the gradle runs on its own scratch worktree (fails; passes with the fix) and ran the minimal test on the released 10.6.0, 10.5.2 and 9.12.3 jars (all fail). Earlier hunt runs: 10.6.0 (released 2026-10-29 from4ae946e5a9b, the RC1 commit; run then on the RC1 core jar) and 14 releases from 5.3.0 to 10.5.2 with the hunt's longer test or ports of it: 10 fail, each passing with its own one-hunk fix except 9.6.0 (no fix run); 8.6.0, 8.11.4, 9.0.0 and 9.3.0 did not hit.
- Finding record
lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:441-469 (at 645d4fe14a20) (nextPendingFlush: fullFlush and numPending read under the monitor at 450-451; the check at 453 with the comment 'don't check if we are doing a full flush', the pool scan at 454 and checkOutForFlush at 459 run after the monitor is released)lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:409-424 (at 645d4fe14a20) (checkOutForFlush never looks at fullFlush), :253-272 and :397-407 (checkout() reads fullFlush under the monitor and parks a pending DWPT in blockedFlushes), :557-622 (markForFullFlush: fullFlush = true at 564, delete-queue swap at 575, fullFlushMarkDone at 616), :723-725 (numBlockedFlushes javadoc)lucene/core/src/java/org/apache/lucene/index/FlushByRamOrCountsPolicy.java:83-90 (at 645d4fe14a20) (markLargestWriterPending marks the largest non-pending DWPT pending, also an idle one of another thread)lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:386-410 (at 645d4fe14a20) and :493-500 (preUpdate / postUpdate -> maybeFlush -> nextPendingFlush), :502-581 (doFlush; the assertion at 508-515 that only full-flush-queue DWPTs flush during a full flush; the flush ticket at 535)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167 (at 645d4fe14a20) (instances are completely thread safe), :1902-1904 (updateDocument: 'The delete and then add are atomic as seen by a reader on the same index'), :4203-4210 and :3731 (commit / prepareCommit: 'All sequence numbers <= this value will be reflected in the commit, and all others will not')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:253-272, (at 645d4fe14a20) :397-424, :441-469, :557-623, :649-664lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:400-410, (at 645d4fe14a20) :493-515, :667-715lucene/core/src/java/org/apache/lucene/index/FlushByRamOrCountsPolicy.java:83-90 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:375, (at 645d4fe14a20) :429; DocumentsWriterDeleteQueue.java:217-276; FrozenBufferedUpdates.java:460-487; DocumentsWriterFlushControl.java:232-238; DocumentsWriter.java:680-681, :733
- Who is affected
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-170 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1938-1947 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-170, (at 645d4fe14a20) :1902-1904, :3730-3732, :4208-4210, :3845-3849lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:253-272, (at 645d4fe14a20) :453, :722-726lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:508-515, (at 645d4fe14a20) :654-658lucene/CHANGES.txt:15736-15741 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/index/TestIndexingSequenceNumbers.java:147-148, (at 645d4fe14a20) :245lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:3163-3181 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:253-272, (at 645d4fe14a20) :409-424, :612, :649-664
- TestUpdateDocumentConcurrentCommit.java (one test, public API):
repro/LFV-1045/TestUpdateDocumentConcurrentCommit.java(90 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the fix diff and the executed runs; the ticket order it shows comes from the TLA+ model's trace and was not instrumented at runtime, and the drawn duplicate assumes an earlier, undrawn flush had frozen v1's own delete (both said on screen).
assets/LFV-1045/bug.png(596 KB)
