All findings · Lucene finding · LFV-1045 · nextpendingflush-stale-fullflush-flushes-new-queue-dwpt

updateDocument racing commit() can leave two live documents for one key in the commit

When several threads call updateDocument while another calls commit(), an indexing thread can flush a post-commit buffer inside the commit, which then holds two live documents for one key. Silent with assertions off; executed failures on main, 10.6.0 and 10 of the 14 releases run from 5.3.0 to 10.5.2.

Summary

Priority
Medium, rank 13A 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
silent wrong results
Since
LUCENE-3090 (2011); first executed failure 5.3.0introduced 2011-05-17 · over 15 years
Affected
main, 10.6.0 and 10 of the 14 releases run from 5.3.0 to 10.5.2 (8.6.0, 8.11.4, 9.0.0 and 9.3.0 ran without a hit; by code since 4.0.0-ALPHA)
Status
documented
Found by
TLA+ model checking of the flush protocol, then a public-API JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

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.

Background

  1. 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.
  2. 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.
  3. 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.
Summary card in four panels. 1, what you do: 16 threads call updateDocument, one thread loops commit() and opens each commit point; expected at most one live document per key and no operation above the cut. 2, inside: on a timeline of four threads, an updater reads fullFlush = false, the commit sets fullFlush = true with a new delete queue, another updater marks idle DWPT 2 pending, and the first updater flushes DWPT 2 inside the full flush, its ticket before DWPT 1's. 3, results on main: a commit holds two versions of key 4-48 and operations above its cut; with assertions on, an AssertionError in doFlush closes the writer; the minimal repro fails 6 of 6 with assertions off and 4 of 4 with them on. 4, the one-hunk fix and its passing runs.
Four threads on one timeline: an updater reads 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.

The finding, step by step

  1. 16 threads replace documents by key with updateDocument, each on its own 50 keys (800 keys), while the test thread loops commit() and counts every key in each commit point. Default codec, 0.25 MB RAM buffer, JVM pinned to 2 CPUs.
  2. 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 number commit() returns.
  3. 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.doFlush that closes the writer.
  4. 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.
  5. 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.

Who is affected

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.

The code (main 645d4fe14a20, lucene/core/src/java/org/apache/lucene/index/)

  1. Indexing threads help flush. After every update DocumentsWriter.postUpdate calls maybeFlush (DocumentsWriter.java:400-410, with the default checkPendingFlushOnUpdate), which calls flushControl.nextPendingFlush() (:493-500).
  2. The stale check. nextPendingFlush (DocumentsWriterFlushControl.java:441-469) copies fullFlush and numPending into locals under the monitor (:450-451) and releases it (:452). Then if (numPending > 0 && fullFlush == false) { // don't check if we are doing a full flush (:453) scans the pool and returns checkOutForFlush(next) for the first flush-pending DWPT it can lock (:454-459).
  3. No second look. checkOutForFlush (:409-424) is synchronized but never reads fullFlush.
  4. The full flush starts in the gap. commit() reaches DocumentsWriter.flushAllThreads (DocumentsWriter.java:667-715), whose markForFullFlush sets fullFlush = true and swaps the delete queue under the monitor (DocumentsWriterFlushControl.java:564, :575), then collects the old queue's DWPTs (:580-606).
  5. 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).
  6. The sibling path does it right. checkout() reads fullFlush under the monitor and parks such a DWPT in blockedFlushes until finishFullFlush (DocumentsWriterFlushControl.java:253-272, :397-407, :649-664).
  7. Result. The stale thread flushes a new-queue DWPT inside the full flush; flushAllThreads waits for it (DocumentsWriter.java:690) and the commit publishes its segment and its frozen new-queue deletes. DocumentsWriter.java:508-515 asserts that only the full flush's queue flushes during it. That assertion is armed only after markForFullFlush returns (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 -ea the test shows either the assertion or the duplicate.

Why a delete goes missing

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.

Same root, other symptoms

  • 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 updateDocument on one writer while commit() runs (softUpdateDocument takes the same updateDocuments path by code reading, IndexWriter.java:1938-1947; not run); an indexing thread descheduled inside a two-statement window of nextPendingFlush just 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; any Directory by code reading (every run used ByteBuffersDirectory); no opt-in feature or expert API. IndexWriter is 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 tragic AssertionError that 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 NoMergePolicy only 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 softUpdateDocument the same update path by code reading, but only commit(), updateDocument and ByteBuffersDirectory were run.

Minimal repro

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.

TestUpdateDocumentConcurrentCommit.java (one test, public API)90 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 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();
        }
      }
    }
  }
}
Command
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 or AssertionError: expected: DWDQ: [ generation: 811 ] but was: ... 812 in DocumentsWriter.doFlush, which closes the writer.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the one-hunk fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64, pinned to 2 CPUs with taskset (the only platform run; the code path is pure Java)
Command (environment)
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)

Observed output

# 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 pass

Why it looks unintended

  • updateDocument promises 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 doFlush asserts 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 on prepareCommit, :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); numBlockedFlushes describes a DWPT that "needs flushing but must not be flushed until the full flush has finished" (:722-726); doFlush asserts that only the full flush's queue flushes during it (DocumentsWriter.java:508-515), and assertTicketQueueModification says "only modifications from the current flushing queue are permitted while doing a full flush" (:654-658). prepareCommitInternal clones 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.testNeverCheckOutOnFullFlush asserts that nothing is checked out once a full flush has started through checkoutLargestNonPendingWriter. No test targets the nextPendingFlush window; concurrent stress tests such as the @Nightly testStressConcurrentCommit can reach it only by chance (nextPendingFlush runs 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 assertTicketQueueModification assertion) and #13127/#13571 (seen as maxSeqNo assertions), 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.

Suggested fix

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.

Re-check fullFlush under the lock before the check-out (validated) (recommended)

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 (checkOutForFlush already runs under the DWPT lock and is synchronized); 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 645d4fe14a20 with 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 the doFlush:515 assertion (assertions on, 355 s).
  • Unpinned, with the fix: :lucene:core:test for TestIndexWriter*, TestDocumentsWriter*, TestFlushByRamOrCountsPolicy, TestIndexingSequenceNumbers, TestStressNRT, TestStressIndexing, TestStressIndexing2 and TestNRTThreads, assertions on: 30 suites, 371 tests, 28 skipped, 0 failures (without tests.nightly, TestNRTThreads, TestStressIndexing and the @Nightly testStressConcurrentCommit were among the skipped).
  • :lucene:core:checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass 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 in TestIndexWriterThreadsToSegments, TestFlushByRamOrCountsPolicy, TestIndexWriterDelete, TestStressNRT, TestDocumentsWriterPerThreadPool and TestDocumentsWriterDeleteQueue ran with 0 failures (the invocation also listed the nightly-only TestNRTThreads, which ran nothing, so the harness labelled it no_tests); nightly, TestNRTThreads and TestIndexWriterDelete (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.

Block the buffer like checkout() does (untested)

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.

Hold the lock across the scan (untested)

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.

Downstream note

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.

How it was found

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), kept nextPendingFlush's fullFlush read and its pool scan as two steps, as in the code. TLC violated NoBad at depth 27 and TicketOrder at 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 updateDocument threads, a commit() 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.

Prior reports, history and checks

Prior reports

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 main 716d6f216f52. 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).
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2916 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-26nextPendingFlush 0 hits, "expected: DWDQ" 0 hits; only same-symptom bugs fixed elsewhere
Open PRs touching DocumentsWriter*, FlushByRamOrCountsPolicy, FlushPolicy, IndexWriter.java2026-10-29diffs read (9 + 2 PRs)none changes nextPendingFlush or checkOutForFlush
Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive2026-10-2920 list and 4 JIRA queriesno report
Elasticsearch, OpenSearch, Solr; web2026-10-292 downstream code and 6 web queriesno report of this mechanism
GitHub refresh2026-10-296 issue and PR searches; the items created that day; open PR #16867; upstream main file historynothing new; not fixed

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions off and on; passes with the fix
10.6.0 (released 2026-10-29 from 4ae946e5a9b, the RC1 commit) affectedminimal 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 affectedminimal 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 affectedport 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 affectedport 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 reproducedruns of 60-600 s did not hit; same code by reading
3.6.2 not affectedpasses, assertions off and on (no DWPT; earlier 3.x releases predate DWPT, by code)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
TLA+ model (TLC) proof-onlyNoBad 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 candidateLonger 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 realOwn 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.

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,497 words

Finding record

LFV-1045, snapshot of 2026-11-06

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 main 716d6f216f52 has no change to DocumentsWriterFlushControl.java since): 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 from 4ae946e5a9b, 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.

Lucene and upstream sources

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-664
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:400-410, (at 645d4fe14a20) :493-515, :667-715
  • lucene/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-3849
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:253-272, (at 645d4fe14a20) :453, :722-726
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:508-515, (at 645d4fe14a20) :654-658
  • lucene/CHANGES.txt:15736-15741 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexingSequenceNumbers.java:147-148, (at 645d4fe14a20) :245
  • lucene/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

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

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.