All findings · Lucene finding · LFV-471 · try-modify-commit-seqno-boundary

tryDeleteDocument and tryUpdateDocValue during commit() are numbered after the commit, yet land in it

If another thread calls tryDeleteDocument or tryUpdateDocValue while commit() is flushing, the call returns sequence number 8 and the commit returns 7, yet the change is in that commit, although commit() promises that higher numbers are not. rollback() keeps such a change; every release since 6.2.0 has it.

Summary

Priority
Low, rank 284Only expert try* users who treat a concurrent commit's sequence number as an exact cut are hurt, and the error only over-includes changes.
Impact
silent wrong results
Since
6.2.0 (LUCENE-7302, 2016); tryUpdateDocValue since 7.4.0introduced 2016-05-24 · over 10 years
Affected
all 89 releases 6.2.0 to 10.5.2, and main
Status
documented
Found by
TLA+ model checking (TLC) of the flush control and delete queue

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Every IndexWriter change returns a sequence number, and commit() returns the number that splits all changes into "in this commit" and "not in it".

Summary card titled 'Lucene: a try* change numbered after the commit is inside the commit'. Panel 1, You do: w.addDocument(a) returns 1, w.addDocument(b) returns 2, an NRT reader holds segment _0 = [a, b], w.addDocument(c) returns 5 and is buffered; thread A calls s = w.commit(), thread B meanwhile calls t = w.tryDeleteDocument(nrt, 0); s = 7, t = 8, and doc a is deleted in commit 7; the commit() javadoc quote. Panel 2, Why (IndexWriter.java at ea57360912ed): flushAllThreads() (:3809) fixes the commit's number, 7, and swaps in a new delete queue that counts on from 8; tryModifyDocument (:1791) is synchronized on the writer and never takes fullFlushLock (:3806), and the commit flushes without the writer's monitor; so a try* call runs mid-commit, deletes a in _0's pooled live docs and returns 8 (:1824); then the commit writes those live docs (:3827) and takes them into the commit point (:3850). Panel 3, expected versus actual contents of commit 7 for five calls, each numbered 8 against 7: deleteDocuments(Term), deleteDocuments(TermQuery) and updateNumericDocValue keep a, b, c and v=1 as expected; tryDeleteDocument leaves b, c and tryUpdateDocValue leaves v=42, both marked wrong. Footer: executed on every release 6.2.0 to 10.5.2 and main (tryUpdateDocValue from 7.4.0).
The two threads and their numbers, the four code steps that let the try call slip into the commit, and what commit 7 contains for each of the five calls. Select the still to open it at full size. Generated with AI, checked against the code.
  1. A writer holds document a (v=1), visible in an NRT reader nrt, plus one newly buffered document. Thread 1 calls commit(); it fixes its sequence number and starts writing the buffered document's segment.
  2. Meanwhile thread 2 calls tryDeleteDocument(nrt, a), or tryUpdateDocValue(nrt, a, v=42). The call returns 8; the commit returns 7.
  3. You expect commit 7 to leave a alone: commit() promises "All sequence numbers <= this value will be reflected in the commit, and all others will not." deleteDocuments(Term) in the same moment also gets 8, and it does stay out.
  4. Instead commit 7 already has a deleted, or v=42. The try call took its number from the new delete queue the commit had just started, but wrote its change into the segment the commit was about to snapshot.
  5. No error, CheckIndex is clean in the rollback probe, and rollback() keeps the change: in a rollback variant, a soft-deleted document un-deleted this way (seqNo 9, commit 8) stays visible to searches after the rollback.

Who is affected

Applications that call the expert try* methods while another thread commits, and use the commit's number as an exact cut: rollback(), log replay, replication or reconciliation. Default settings otherwise. Under concurrency it is common (3,417 of 5,981 try deletes mislabelled in one stress run), but it only adds changes: in every run nothing at or below the commit was lost.

Mechanism, terms and numbers822 words

Every IndexWriter change returns a sequence number, and commit() promises that all numbers at or below its own are in the commit and all others are not. tryDeleteDocument and tryUpdateDocValue, the expert calls that change one document by its docID in an NRT reader, break that promise when they run while another thread is inside commit(): in the repro they return 8, the commit returns 7, and the change is in commit 7. The term-based deleteDocuments(Term), deleteDocuments(Query) and updateNumericDocValue(Term) in the same window also get 8 and correctly stay out. The cause is that the shared tryModifyDocument takes its number from the new delete queue that the commit's full flush has already swapped in, and applies the change straight to the pooled segment reader, which the commit then writes into its snapshot. Applications that use the commit's number as an exact cut (rollback, replay, replication) get a wrong cut; with rollback() such a change survives. It is over-inclusion only: in every run, nothing numbered at or below a commit was missing, and CheckIndex was clean where it ran. Every release since 6.2.0 (7.4.0 for tryUpdateDocValue) and main have it.

Terms

  • Sequence number (seqNo): every IndexWriter method that changes the index returns one. The class javadoc says it "expresses the effective order in which each change was applied", and commit() returns one "describing which changes are in the commit point and which are not" (IndexWriter.java:117-119).
  • try* methods: tryDeleteDocument(reader, docID) and tryUpdateDocValue(reader, docID, fields) change one document by its docID in an NRT reader of this writer. They return a seqNo, or -1 when they cannot apply the change. tryDeleteDocument also returns a number when the document was already deleted. The tryUpdateDocValue javadoc says a null value can be used to un-delete a soft-deleted document.
  • Full flush: what commit() (and an NRT reopen, and flush()) does first: it fixes the commit's seqNo, starts a new delete queue whose numbers are higher, and writes the documents buffered at that moment to segments.

What happens in the repro (one writer, two threads)

operation run while the commit writes its segmentits seqNocommit seqNoin the commit?contract
deleteDocuments(Term)87noholds
deleteDocuments(Query)87noholds
updateNumericDocValue(Term)87noholds
tryDeleteDocument(nrt, a)87yesbroken
tryUpdateDocValue(nrt, a, v=42)87yes (42 committed)broken

Without a concurrent commit the try* calls behave: a try delete before the commit gets a number at or below it and is in it; one after gets a higher number and is not (the JUnit's control tests tryDeleteDocument; the rollback probe's and the release probes' before/after controls show the same for tryUpdateDocValue).

The mechanism (main ea57360912ed)

  1. The commit fixes its number and swaps the queue first. prepareCommitInternal takes fullFlushLock (IndexWriter.java:3806) and calls flushAllThreads() (:3809). Under the DocumentsWriter monitor (DocumentsWriter.java:674-682) this runs markForFullFlush (DocumentsWriterFlushControl.java:557-575): fullFlush = true, and resetDeleteQueue (DocumentsWriter.java:588-598) makes the old queue's maximum the commit's number and installs a new queue that starts above it (DocumentsWriterDeleteQueue.java:616-630).
  2. Then it flushes without holding the writer's monitor. It writes the buffered segments and, at the end of the flush, publishes them (DocumentsWriter.java:580 → IndexWriter.java:449; :3819 publishes any leftover), then applies deletes (:3825). It holds fullFlushLock throughout, and the writer's monitor only briefly to publish each segment (:2953), until it takes it for the snapshot at :3826.
  3. A try* call runs in that gap. Both methods are synchronized on the writer (:1688, :1721) and share tryModifyDocument (:1791-1827). It applies the change directly to the pooled segment reader (:1823) and returns docWriter.getNextSequenceNumber() (:1824), which now comes from the new queue: 8, above the commit's 7. It never takes fullFlushLock and never checks whether a full flush is running, so any try* call that returns after the queue swap and before :3826 is caught.
  4. The commit then snapshots the change. Back under the writer's monitor (:3826) it calls writeReaderPool(true) (:3827), which writes the pooled readers' pending deletes and doc-values updates, the try* change included, and clones the segment infos into the commit point (:3850). The comment there places the clone so that partial compound changes (a delete without the add of an updateDocument) cannot "sneak into the commit point" (:3845-3849).
  5. Why the term-based calls are fine. They go through DocumentsWriter.applyDeleteOrUpdate (DocumentsWriter.java:153-165), synchronized "to make sure we don't replace the deleteQueue while applying this update / delete": number and change land in the same new queue, which this commit does not freeze.

The window runs from the queue swap to the commit's final take of the writer's monitor (:3826) and includes writing the buffered segments, so it is wide: a stress test without any hook hits it constantly.

Exposure and likelihood467 words
  • Who is hit. Applications that call tryDeleteDocument or tryUpdateDocValue while another thread runs commit() or prepareCommit(), and that act on the commit's number. LUCENE-7302 added sequence numbers so that callers "can know exactly which operations made it into the commit and which did not". Typical cuts: aborting with rollback(), documented to return to "the state ... when commit() was last called" (IndexWriter.java:2567-2571); replaying a transaction log from the commit's number; reconciling a replica or a system of record at that cut. Sequence numbers are transient and valid only within one IndexWriter instance (IndexWriter.java:119-121), so the cut matters while that writer lives. prepareCommit() shares the code path (read from the source; no executed test calls it directly).
  • What goes wrong (executed on main with assertions on and off, 10.5.2 and 9.12.3). After rollback(), a tryDeleteDocument numbered 9 above a commit numbered 8 is still applied, so a document the application believes live is gone; a tryUpdateDocValue un-delete of a soft-deleted document (9 against 8) survives, so the document is visible to searches. Before/after controls behave; CheckIndex is clean.
  • What does not go wrong. Idempotent replay from the commit's number still converges (it re-applies a change that is already there). In every run, no operation numbered at or below a commit was missing from it, and CheckIndex found no corruption where it ran. rollback() itself does what its javadoc says; what is wrong is the number that labels the change as outside the commit.
  • Default or opt-in. No explicit codec or directory override: the portable probe ran with the normal default codec and a plain NIOFSDirectory, and the JUnit with the test framework's randomized codec and directories (for example Asserting(Lucene104)). The repro only sets NoMergePolicy, which keeps the try* javadoc's success condition (the segment "has not been merged away") true; merges (and with them the merged-away -1 path) were not exercised, while fully deleted segments that the try delete drops were (the finder's one-document repro, the independent reproduction's stress test). The opt-in is the expert try* API. Read-only code reads (default branches, not exhaustive): Elasticsearch's engine writer fails tryDeleteDocument only when assertions are enabled (otherwise it calls through to Lucene), OpenSearch's engine writer always rejects it, and an OpenSearch sandbox plugin calls it but checks only for -1 and never uses the sequence number; no tryUpdateDocValue caller was found in Elasticsearch, OpenSearch or Solr; custom applications were not surveyed.
  • How often. Two documents suffice, and the repro failed on every run (40 of 40 try* runs over 20 seeds). Without hooks, a stress test that commits in a loop while another thread try-deletes mislabelled 3,417 of 5,981 successful try deletes in one run and 14,698 of 20,992 over five runs; every mislabelled call overlapped its commit in wall-clock time.

Minimal repro

The test shows that while commit() flushes a buffered segment (after it has fixed its sequence number), another thread calls tryDeleteDocument(nrtReader, 0); the delete gets a number above the commit's, yet it is in that commit (tryUpdateDocValue shares the code path).

TestTryDeleteDocumentDuringCommit.java (JUnit test)84 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ThreadInterruptedException;

public class TestTryDeleteDocumentDuringCommit extends LuceneTestCase {

  private volatile Runnable duringCommitFlush;
  private Thread deleter;

  public void testSeqNoAboveCommitButDeleteInCommit() throws Exception {
    Thread committer = Thread.currentThread();
    Directory dir =
        new FilterDirectory(newDirectory()) {
          @Override
          public IndexOutput createOutput(String name, IOContext context) throws IOException {
            Runnable r = duringCommitFlush;
            if (r != null && Thread.currentThread() == committer) {
              duringCommitFlush = null;
              deleter = new Thread(r);
              deleter.start();
              try {
                deleter.join(2000);
              } catch (InterruptedException e) {
                throw new ThreadInterruptedException(e);
              }
            }
            return super.createOutput(name, context);
          }
        };
    IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
    w.addDocument(new Document());
    DirectoryReader nrt = DirectoryReader.open(w);
    w.addDocument(new Document());
    AtomicLong deleteSeqNo = new AtomicLong();
    duringCommitFlush =
        () -> {
          try {
            deleteSeqNo.set(w.tryDeleteDocument(nrt, 0));
          } catch (IOException e) {
            throw new UncheckedIOException(e);
          }
        };
    long commitSeqNo = w.commit();
    deleter.join();
    boolean deleteInCommit;
    try (DirectoryReader committed = DirectoryReader.open(dir)) {
      deleteInCommit = committed.numDocs() == 1;
    }
    nrt.close();
    w.close();
    dir.close();

    // commit(): "All sequence numbers <= this value will be reflected in the commit, and all others will not"
    assertEquals(
        "tryDeleteDocument seqNo=" + deleteSeqNo + ", commit seqNo=" + commitSeqNo + ", in commit",
        deleteSeqNo.get() <= commitSeqNo,
        deleteInCommit);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-471/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestTryDeleteDocumentDuringCommit
Expected vs actual
Expected, per the commit() javadoc ("All sequence numbers <= this value will be reflected in the commit, and all others will not"): seqNo 7 > commit 6, so not in the commit. Actual: tryDeleteDocument seqNo=7, commit seqNo=6, in commit expected:<false> but was:<true>.

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

More files, environment, full output39 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: tryDeleteDocument seqNo=7, commit seqNo=6, in commit expected:<false> but was:<true>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.apache.lucene.index.TestTryDeleteDocumentDuringCommit.testSeqNoAboveCommitButDeleteInCommit(TestTryDeleteDocumentDuringCommit.java:79)

Why it looks unintended

  • commit(): "All sequence numbers <= this value will be reflected in the commit, and all others will not." The try* javadoc makes no exception for concurrent commits.
  • In the same window, term-based deletes and doc-values updates also get 8 and stay out of the commit.
  • deleteAll takes fullFlushLock for "point in time semantics"; the try* path skips that coordination.
The full argument359 words
  • The contracts. commit() (IndexWriter.java:4203-4205): "it returns the sequence number such that all indexing operations prior to this sequence will be included in the commit point, and all other operations will not"; @return (:4208-4210, repeated for prepareCommit() at :3730-3732): "All sequence numbers <= this value will be reflected in the commit, and all others will not." The class javadoc (:117-119) covers "each method that changes the index". The try* javadoc (:1680-1681, :1710-1711) promises "a valid (> 0) sequence number". IndexWriter is "completely thread safe, meaning multiple threads can call any of its methods, concurrently" (:167-168). CHANGES.txt:8925-8927 (LUCENE-7302): methods that change the index "now return a long "sequence number" indicating the effective equivalent single-threaded execution order".
  • Upstream tests. TestIndexingSequenceNumbers checks exactly this boundary under concurrent commits, for the queue-based operations only; it never calls a try* method. Seven test classes call try* (TestTryDelete, TestRollingUpdates, TestIndexWriter, TestIndexWriterDelete, TestInfoStream, TestSoftDeletesRetentionMergePolicy, TestMixedDocValuesUpdates); none compares a try* number with a commit's, and the two that run try* alongside commits use the number only to retry on -1.
  • History and precedent. LUCENE-7302 (2016) turned tryDeleteDocument from boolean into this seqNo; the violated javadoc came three days later in the same series, before any release. Maintainers fixed full-flush seqNo inconsistencies as bugs before: LUCENE-9304 (2020, which changed this very return line while fixing getMaxCompletedSequenceNumber) and the seqNo races of #13127 and #13571, fixed by #13169 (first in 9.11.0) and #13627 (first in 9.12.0). On java-user@ in 2016 the LUCENE-7302 author wrote: "all sequence numbers <= the sequence number returned from IW.commit "made it" into the index, and all other ops did not make it."
  • The code's own guards. DocumentsWriter.applyDeleteOrUpdate (DocumentsWriter.java:153-165) is synchronized "to make sure we don't replace the deleteQueue while applying this update / delete"; deleteAll takes fullFlushLock for "point in time semantics" (IndexWriter.java:2757, :2764); prepareCommitInternal clones while it holds fullFlushLock and the monitor so that partial compound changes cannot "sneak into the commit point" (:3845-3849; a comment about operations such as updateDocument, not a general rule).
The devil's-advocate check277 words

The strongest case for "intended", built by the devil's-advocate check: try* is a best-effort expert API; LUCENE-4521 (2012) deliberately made a successful try delete reach the next commit; LUCENE-1335 (2008) declined API promises about concurrent commit, close and addIndexes; the try* javadoc does not link the #sequence_number anchor; and the open #7444 (2015) calls tryDeleteDocument "a trap" and proposes removing it.

Why it fails: inclusion in the commit is fine, but returning a number that says "not in this commit" for a change that is in it is not; the try* numbers come from the same counter as every other operation, and nothing says they are weaker. LUCENE-1335 predates sequence numbers and try*, names admin methods only, and is not in today's docs; the 2016 javadoc is exactly such a promise about concurrent commits. LUCENE-7302 gave try* its number, and its CHANGES entry covers every method that changes the index. #7444 is about rare false returns before sequence numbers existed: it may shape the fix (deprecation), not whether this is a bug. Verdicts: devil's advocate "real, high"; the cross-model works-as-designed review rated the intended argument "weak"; the adjudication "confirmed, high", for the commit/prepareCommit boundary only.

How strong the case still is: a read-only fact check by OpenAI GPT-5.6 rated it moderate overall and weak under the literal javadocs. It is strongest for tryDeleteDocument (an expert, best-effort API; LUCENE-4521 made inclusion in the next commit intentional; the index state matches the serial order "try, then commit", only the number does not) and weaker for tryUpdateDocValue, whose javadoc advertises the soft-delete un-delete. Maintainers may raise these points; the answer is the explicit @return contract and the same-window asymmetry.

Suggested fix

Recommended B: wait for a running full flush (take fullFlushLock before the writer's monitor)

Make the try* methods wait for a running full flush: take fullFlushLock before the writer's monitor, as commit does (+22/-5 in IndexWriter.java). The try call then gets 8 and stays out of commit 7. Validated: the repro and all 8,870 core and 315 misc tests pass. Cost: a try call can block while a flush runs.

public long tryDeleteDocument(IndexReader readerIn, int docID) throws IOException { // was synchronized
  return tryModifyDocumentOutsideFullFlush(readerIn, docID, (leafDocId, rld) -> { /* unchanged */ });
}
// tryUpdateDocValue: the same; buildDocValuesUpdate stays under synchronized (this)

private long tryModifyDocumentOutsideFullFlush(
    IndexReader readerIn, int docID, DocModifier toApply) throws IOException {
  assert Thread.holdsLock(this) == false : "fullFlushLock must be taken before the IW monitor";
  synchronized (fullFlushLock) { // same order as commit, getReader and deleteAll
    return tryModifyDocument(readerIn, docID, toApply); // still synchronized on the writer
  }
}
All options and trade-offs4 options · 756 words

Two code fixes were built and validated in a scratch worktree of ea57360912ed (removed afterwards); two more options are documented. Taking fullFlushLock inside the synchronized try* methods would invert the existing lock order, so both code fixes avoid that.

B: wait for a running full flush (take fullFlushLock before the writer's monitor) (validated by the checks) (recommended)

Drop synchronized from tryDeleteDocument and tryUpdateDocValue and route both through a helper that takes fullFlushLock and then calls the still-synchronized tryModifyDocument. A try call that arrives during a commit, NRT reopen or flush() waits for it to end, then gets a number above the commit's and is not in it. Patch: +22/-5 in IndexWriter.java.

public long tryDeleteDocument(IndexReader readerIn, int docID) throws IOException { // was synchronized
  return tryModifyDocumentOutsideFullFlush(readerIn, docID, (leafDocId, rld) -> { /* unchanged */ });
}
// tryUpdateDocValue: the same; buildDocValuesUpdate stays under synchronized (this)

private long tryModifyDocumentOutsideFullFlush(
    IndexReader readerIn, int docID, DocModifier toApply) throws IOException {
  assert Thread.holdsLock(this) == false : "fullFlushLock must be taken before the IW monitor";
  synchronized (fullFlushLock) { // same order as commit, getReader and deleteAll
    return tryModifyDocument(readerIn, docID, toApply); // still synchronized on the writer
  }
}

Trade-offs

  • A try call now blocks for the rest of a running full flush (writing the buffered segments). Not benchmarked.
  • A caller that calls try* while holding the writer's monitor (the class javadoc already says not to synchronize on the writer) would now risk a deadlock; the added assertion catches it under -ea (with assertions off it would deadlock against a full flush), and no in-tree test trips it. One callback passed: TestSoftDeletesRetentionMergePolicy calls tryDeleteDocument from a merged-segment warmer and is among the 18 closest suites; broader callback and full-flush interactions were not evaluated.
  • The public methods lose the synchronized modifier; they still take the monitor inside.

What the checks validated

  • The repro passes 5 of 5 with assertions on and off and 50 of 50 with tests.iters=10, through gradle and the harness: both try calls return 8 and stay out of commit 7.
  • All of :lucene:core:test: 748 classes, 8,870 tests, 322 skipped, 0 failures. All of :lucene:misc:test (includes TestIndexRearranger): 315 tests, 9 skipped, 0 failures. The 18 closest core suites also with assertions off: 359 tests, 0 failures. :lucene:core:check -x test passes.
  • A hook-free stress test (commits back to back while another thread try-deletes): 0 mislabelled in five runs, with 1 to 6,667 successful try deletes per run, the same range as stock.

A: return -1 while a full flush runs (validated by the checks)

Take the seqNo first, atomically with the start of a full flush (a new DocumentsWriter method, synchronized like the queue swap, returns -1 while isFullFlush()), and apply the change only if a number was given. -1 is already the documented outcome that sends callers to delete by Term or Query; the javadoc gains "or while a commit, NRT reopen or flush is flushing all buffered documents". Patch: +13 in DocumentsWriter.java, +18/-8 in IndexWriter.java.

// DocumentsWriter
synchronized long getNextSequenceNumberOutsideFullFlush() {
  if (flushControl.isFullFlush()) {
    return -1;
  }
  return deleteQueue.getNextSequenceNumber();
}
// IndexWriter#tryModifyDocument, inside synchronized (bufferedUpdatesStream)
final long seqNo = docWriter.getNextSequenceNumberOutsideFullFlush();
if (seqNo == -1) {
  return -1;
}
toApply.run(docID, rld);
return seqNo;

Trade-offs

  • Measured: in the hook-free stress test with back-to-back commits, two of five runs had 0 successful try deletes and the others 226 to 3,945. Under frequent commits or NRT reopens many calls fall back to the term path, and a caller that retries on -1, as the tryUpdateDocValue javadoc suggests, could spin for the length of a flush.
  • It also refuses during NRT reopens, which make no seqNo exclusion promise. A narrower variant (not tested) would refuse only during a commit's full flush.
  • IndexRearranger treats -1 as "should never happen"; it does not commit concurrently, so it is not hit.

What the checks validated

  • The repro passes (both try calls return -1 and change nothing) with assertions on and off and 50 of 50 iterations.
  • The 18 closest core suites (359 tests) and TestIndexRearranger pass. The full suites were not run with A.

C: document it

Say in the try* javadoc (or in the commit() @return) that a try* change made while a commit runs may be in that commit even though its number is higher.

Trade-offs

Cheapest, no behaviour change; leaves the rollback and cut problems to applications. Not tested.

D: deprecate the try* methods

The direction of the open #7444 ("remove or fix IndexWriter.tryDeleteDocument").

Trade-offs

Larger API change; maintainers may prefer to answer there. Not tested.

Downstream note

Why: rollback() returns to the last commit, but a try* change numbered after it survives. With the soft-delete un-delete that the tryUpdateDocValue javadoc describes, a document the application meant to keep deleted stays searchable. Only the application's own calls and commit timing trigger it, so it is out of scope of Lucene's threat model; no concrete exploit is known.

How it was found

TLA+ model checking (TLC) of DocumentsWriter's flush control and delete queue. The plan asked for "a commit with sequence number s contains exactly the operations numbered <= s"; the finder added tryDeleteDocument to the model, TLC found a 20-state counterexample, and a public-API test reproduced it on the real jars.

The method in full199 words

Method: TLA+ model checking (TLC), a hunt unit (2026-10-26), on main ea57360912ed.

  • The unit's plan asked for a TLA+ model of DocumentsWriterFlushControl, the per-thread writer pool, the delete queue (slices, seqNo assignment, queue swap on full flush) and the flush queue, with indexing threads, an NRT refresher and a committer, and for the invariant "an NRT reader or commit with seqNo s contains exactly the ops with seqNo <= s (the commit javadoc)". It did not name the try* methods; its dedup list named two other try* rows (LFV-009, LFV-010).
  • The finder's model (DWFlush.tla, about 500 lines with a file:line map) added tryDeleteDocument as an action that takes its number from the current queue under the writer's monitor only. With configuration MC_D_try (two indexers, one try delete on a published document, NRT reopen and commit), TLC found the invariant violated in a 20-state trace (112,706 states generated, 38,380 distinct).
  • The finder replayed the trace as a public-API JUnit on the stock jars (two schedules: an InfoStream message and a FilterDirectory#createOutput hook), which failed with 8 against 7. Independent checks followed (see Checks), and this doc added the minimal five-test version and the fix validation.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26): GitHub issues, PRs and commits of Lucene and three downstream projects, the JIRA archive, four mailing lists, the web, and the changed files of all 409 open PRs. The nearest item is the open #7444 ("remove or fix tryDeleteDocument", 2015, about false returns).

Searches6 searches

No issue, PR, commit, JIRA entry, list thread or downstream report describes a try* change crossing the commit sequence-number boundary, and upstream has not fixed it. Keyword search cannot prove absence, and private security reports cannot be searched. In-hunt sibling (the hunt's own, not prior art): LFV-014, addIndexes and its sequence number, the same design gap at a different fix site.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, the archived lucene-solr)2026-10-26a scripted prior-report search in three term variants (48 issue/PR queries) plus 40 GraphQL phrasings: try* methods, tryModifyDocument, seqNo with commit or full flush, markForFullFlush, getNextSequenceNumber, "All sequence numbers", "reflected in the commit", fullFlushLock, LUCENE-7302, LUCENE-6384; 25 issues and PRs read in fullrelated only: #7444 (open, false returns), lucene-solr#1427 / LUCENE-9304 (same return line, NRT visibility), #13169 (9.11.0: made the queue swap atomic for getNextSequenceNumber callers, the method try* uses; about old-queue numbers above the old maximum, not commit contents), #13127, #13571 and #13627 (9.12.0: DWPT-path seqNo races, opposite direction), #13352 (open, DWPT assertion), #5587 / LUCENE-4521 (try delete must reach the next commit), #16847 (open draft PR, docID to long)
Open PRs2026-10-26changed-file lists of all 409 open apache/lucene PRs; the 24 touching IndexWriter or DocumentsWriter* greppedonly #16847 (an open draft PR) edits tryModifyDocument (docID int to long)
Commits and upstream state2026-10-269 commit queries; upstream main 49483e82b357 compared with the tested treeIndexWriter.java byte-identical; no CHANGES entry
Downstream (Elasticsearch, OpenSearch, Solr) and all of GitHub2026-10-266 tool queries and 17 GraphQL searchesno report; Elasticsearch's engine writer fails tryDeleteDocument only with assertions enabled, OpenSearch's always rejects it, and an OpenSearch sandbox plugin calls it but checks only for -1
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2660 queries; 5 threads (23 mails) readno report; java-user@ 2016 and 2017 and dev@ 2023 restate the contract (intent evidence)
JIRA archive and web2026-10-2612 archive and 8 JQL queries; 18 web queriesno report

History

Introduced in 6.2.0 by LUCENE-7302 (#8356, 7ee1f422458, 2016), which made tryDeleteDocument return a sequence number with no coordination with the commit's full flush; tryUpdateDocValue (LUCENE-8297, 7.4.0) shares the code. All 89 releases 6.2.0-10.5.2 and main are affected.

Versions tested12 rows

Introduced by LUCENE-7302 (#8356, @mikemccand), "IndexWriter should tell you the order of indexing operations": 7ee1f422458 on branch_6x, "cutover all IW APIs that change the index to return seq no" (authored 2016-05-24; the same patch reached master through the merge b1fb142af00, 2016-06-05). It changed tryDeleteDocument from boolean to the current delete queue's next number. The javadoc it violates came in 5361d67996a (2016-05-27), same series, before any release. tryUpdateDocValue came with LUCENE-8297 (#9344, @s1monw, 48a2138e899, 2018-05-05), with the same behaviour and the shared tryModifyDocument, the fix site on main. The behaviour itself is older (on 6.0.0-6.1.0 a try delete inside the commit's flush already lands in that commit); what 6.2.0 added is the promise it breaks. Not fixed: upstream main 49483e82b357 (2026-10-26) has IndexWriter.java unchanged, and the finder's test still fails on main a9779ef3c193.

Versions and branches executed, with results
Version or commitVerdictResult
6.0.0, 6.0.1, 6.1.0 no seqNosthe probe does not compile: no sequence numbers (the try delete still lands in the commit; nothing promised otherwise)
6.2.0 - 8.5.2 (40 releases) affectedtryDeleteDocument 13 against commit 12, in the commit; controls pass
8.6.0 - 10.5.2 (49 releases) affectedtryDeleteDocument 9 against 8, in the commit; controls pass
7.0.0 - 7.3.1 no tryUpdateDocValuethe tryUpdateDocValue probe does not compile
7.4.0 - 10.5.2 (67 releases) affectedtryUpdateDocValue above the commit, in the commit; controls pass
9.12.3, 10.5.2 affectedthis doc's test: 2 of 5 fail (8 against 7), the 3 controls pass
main ea57360912ed affected2 of 5 fail with assertions on and off (gradle and harness); 40 of 40 try* runs over 20 seeds
main a9779ef3c193 (2026-10-26) affectedthe finder's test fails 2 of 2
88dd1aad774 (parent of 7ee1f422458) bisectcannot express the scenario: tryDeleteDocument still returns boolean
7ee1f422458 (LUCENE-7302, branch_6x) bisecttryDeleteDocument 7 against 6, in the commit; controls pass
b1fb142af00 (merge to master) bisect13 against 12
0600a58f005 / 48a2138e899 (LUCENE-8297) bisectparent has no tryUpdateDocValue; the commit fails 13 against 12

Independent checks

Every check that ran Java says real: an independent reproducer with its own hooks and a hook-free stress test, a devil's advocate with a five-operation control, and an adjudication who re-ran both. Reviews by OpenAI GPT-5.6 could only read code: one called it real, the other's objection was answered by a public-API hook.

Checks and verdicts788 words

All checks ran on main ea57360912ed with assertions on and off and fixed seeds recorded in their result files. Caveat: neither cross-model review could execute Java, so all execution evidence comes from Claude AI tools and the harness. This doc's own checks (a fact check that re-ran the test and both fixes, a read-only cross-model fact check and a diagram check) found wording and diagram errors only; each is resolved.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (TLA+/TLC, then JUnit) candidateTwo schedules fail 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3, and 40 of 40 over 20 iterations: 'tryDelete seqNo=8 commit seqNo=7 deleteInCommit=true'.
Refute review (OpenAI GPT-5.6, read-only) not real, low (procedural)Could not run Java; 9 of its 11 evidence items support the claim, 1 is neutral (it could not run Java) and 1 objects (the InfoStream hook is internal API), an objection the FilterDirectory schedule removes. A separate run re-ran the repro: fails.
Independent reproducer realOwn test (pause at the flushed segment's .si) fails for both try* calls, 20 of 20, on main (-ea, -da), 10.5.2 and 9.12.3; its control passes; a hook-free stress test fails 17 of 20, never in the opposite direction.
Works-as-designed review (OpenAI GPT-5.6, read-only) real, mediumRated the intended argument weak; a separate control (deleteDocuments(Term) in the window passes, both try* fail) ran on main, 10.5.2, 9.12.3 and 25 random seeds.
Devil's advocate real, highBuilt and rejected the LUCENE-1335, LUCENE-4521, javadoc-anchor and #7444 readings; its five-operation control shows only the try* path crossing the boundary (main -ea, -da, 10.5.2).
Adjudication confirmed, highRe-ran the independent reproduction (3 of 4 fail, -ea and -da) and the intended-behaviour check's control; limited the claim to the commit/prepareCommit boundary; called the first score generous.
Follow-up: history, prior art, security new; out of scope of SECURITY.md security199 history run records (6.2.0-10.5.2 fail; every boundary probe that ran had zero control failures), commit-level pinning, prior-art verdict new, and the rollback probe (2 of 6 fail on main, 10.5.2, 9.12.3; CheckIndex clean).
Registry re-runs and audit keepFresh seeds on main: the finder's test, the independent reproduction's suite (hook-free stress: 3,417 of 5,981 mislabelled, 0 missing) and the intended-behaviour check's control all fail as expected with -ea and -da.
This doc: minimal test and fixes reproduces; two fixes validatedThe five-test repro fails exactly the two try* tests through gradle and the harness (-ea, -da, 20 seeds, 10.5.2, 9.12.3); fix B passes it and all core and misc tests; fix A passes it and the closest suites.
Fact check of this doc (re-ran the test and both fixes) real; 3 wording errors, fixedRe-ran the gradle command of the brief verbatim (assertions on and off), the harness on main -ea and -da, 10.5.2, 9.12.3 and 5 iterations, and fixes B and A: every run matches (5 tests, exactly the 2 try* tests fail, 8 against 7; both fixes pass), and :lucene:core:check -x test passes with the test. Three wording errors (the downstream sentence, "the commit's number plus one", one evidence count) and 8 minor items, all taken in this doc.
Cross-model fact check (OpenAI GPT-5.6, reading only) real; 6 medium text errors, fixed"The core bug is real." 6 medium errors (the default codec, the NoMergePolicy wording, the release of #13169, the 199-record count, the merged-segment-warmer callback, the 2 s wait), 10 overclaims and 9 missing caveats; the errors are fixed and the overclaims and caveats taken, with two answered. It rated the intended-behaviour case moderate overall and weak under the literal javadocs, which this doc now states.
Diagram check 1 error, fixed and re-renderedEvery value and line number matched the code and the probe runs except one: the diagram placed the publish of _1 at IndexWriter.java:3819, after flushAllThreads(); its stack-trace probe showed it happens at the end of the flush inside flushAllThreads() (DocumentsWriter.java:580 → IndexWriter.java:449).
Correction to the registry row 3 wordings out of dateThe registry row has three wordings this doc corrects: its summary says the call returns "the commit's number plus one (8 against 7)" (it returns a number above the commit's; commit plus one only for the first operation after the queue swap, as in the repro; the stress runs show, for example, 1585 against 1140); its verification says the refute review had "9 of its 10 evidence items" supporting the claim (11 items: 9 support, 1 neutral, 1 objects); its affected_versions says controls pass in every one of the "199 executed history runs" (the 199 records include 14 compile errors and 5 mechanism probes without controls). This doc's text is the corrected account.

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

Finding record

LFV-471, snapshot of 2026-11-06

Registry title
tryDeleteDocument / tryUpdateDocValue running while commit() flushes returns a sequence number above the commit's, yet its change is in that commit
Area
index / IndexWriter sequence numbers (tryDeleteDocument, tryUpdateDocValue vs commit)
Priority
Low, rank 284: Only expert try* users who treat a concurrent commit's sequence number as an exact cut are hurt, and the error only over-includes changes.
Impact class
silent wrong results
Impact note
Default IndexWriterConfig, codec and directory, but only applications that call the expert tryDeleteDocument or tryUpdateDocValue while another thread is in commit() or prepareCommit() and that treat the commit's sequence number as an exact cut (rollback() to discard later changes, a point-in-time, replication or reconciliation cut) are hurt; the error is over-inclusion only (a change numbered above the commit is durable in it, and no operation numbered at or below it was ever missing), idempotent replay from the commit's number still converges, nothing is corrupted (CheckIndex clean), the scale is tiny (two documents), and it is in every release from 6.2.0 (tryUpdateDocValue: 7.4.0) through 10.5.2 and main.
Summary
You call tryDeleteDocument or tryUpdateDocValue with an NRT reader while another thread is inside commit(), after the commit has fixed its sequence number and before it snapshots its segments. You expect what the commit javadoc promises ('All sequence numbers <= this value will be reflected in the commit, and all others will not'): a change whose returned number is above the commit's is not in that commit. Instead the call returns the commit's number plus one (8 against 7), yet the delete or doc-values update is already in the commit point, while deleteDocuments(Term), deleteDocuments(Query) and updateNumericDocValue(Term) in the same window get 8 too and correctly stay out.
Introduced
7ee1f422458 (branch_6x; the same change is e4a21330a20 on the sequence_numbers branch, identical patch-id, merged to master in b1fb142af00): LUCENE-7302 = apache/lucene#8356 (mikemccand), 'cutover all IW APIs that change the index to return seq no', changed tryDeleteDocument from boolean to long and returned the current delete queue's next number with no coordination with the full flush that fixes commit()'s number [exec: commit-level runs; the parent 88dd1aad774 cannot express the scenario, 7ee1f422458 and b1fb142af00 fail, controls pass]. The violated javadoc (commit/prepareCommit @return) was added three days later in the same series (5361d67996a), before any release. tryUpdateDocValue: LUCENE-8297 = apache/lucene#9344 (s1monw), 48a2138e899 (branch_7x) / b0b32931b28 (master), which added the method with the same behaviour and moved both bodies into the shared tryModifyDocument, the fix site on main [exec: parent 0600a58f005 lacks the method, 48a2138e899 fails] (2016-05-24 (author date of 7ee1f422458 / e4a21330a20; on master 2016-06-05 via b1fb142af00; on branch_6x 2016-06-11); tryUpdateDocValue: 2018-05-05 (committed 2018-05-07))
First affected version
6.2.0 [exec] for tryDeleteDocument (6.0.0, 6.0.1 and 6.1.0 cannot express the scenario: no sequence numbers, the probe does not compile); 7.4.0 [exec] for tryUpdateDocValue, the release that added it
Affected versions
tryDeleteDocument: all 89 releases from 6.2.0 through 10.5.2 [exec] and main ea57360912ed [exec, -ea and -da]; tryUpdateDocValue: all 67 releases from 7.4.0 through 10.5.2 [exec] and main; also with -da on 6.2.0, 7.4.0, 9.12.3 and 10.5.2 [exec]; 199 executed history runs, controls pass in every one. Not fixed upstream: upstream main 49483e82b357 (2026-10-26) has IndexWriter.java byte-identical to the tested tree [read]. The NRT-reopen variant was not tested and is not claimed
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
formal: TLA+/TLC
Upstream and prior reports
none found (2026-10-26, read-only, plus the finder's run and the intended-behaviour check's searches; the prior-report record): three runs of the scripted prior-report search (48 GitHub issue and PR, 9 commit, 6 downstream-repository, 60 mailing-list queries over dev@, java-user@, issues@ and commits@, 12 JIRA-archive and 18 web queries, 0 errors), 57 GitHub GraphQL searches (apache/lucene, the archived lucene-solr, Elasticsearch, OpenSearch, Solr, all of GitHub), 10 REST searches (2 rate-limited, covered by GraphQL), 8 JIRA JQL queries, 25 issues and PRs and 5 list threads read in full, and the changed files of all 409 open apache/lucene PRs; nothing reports a try* change crossing the commit sequence-number boundary. Nearest, not the same bug: open #7444 (LUCENE-6384, 2015, 'remove or fix IndexWriter.tryDeleteDocument', about rare false returns, before sequence numbers existed); lucene-solr#1427 (LUCENE-9304, 2020, changed the same return line while fixing getMaxCompletedSequenceNumber during a full flush, discussion about NRT visibility only); #13127/#13169 and #13571/#13627 (fixed in 9.12.0: DWPT-path seqNo races at the same queue advance, the opposite direction); open #13352 (DWPT seqNo assertion, no try* call); #5587 (LUCENE-4521, a successful try delete must reach the next commit); open PR #16847 changes tryModifyDocument only from int to long docIDs. Not fixed upstream (upstream main 49483e82b357).
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; runs 2026-10-26) with assertions on and off, through gradle and the hunt's JUnit harness (20 derived seeds), and the 10.5.2 and 9.12.3 release jars; every release 6.2.0-10.5.2 with version-portable probes (199 history run records in all, 14 of them compile errors where the API is missing) and the LUCENE-7302 and LUCENE-8297 commits with their parents; the finder's test on main a9779ef3c193 (2026-10-26); fix options A and B in a scratch worktree of ea57360912ed (B through all of :lucene:core:test, all of :lucene:misc:test and :lucene:core:check -x test; A through the 18 closest suites and TestIndexRearranger), removed afterwards.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:115-121 (class javadoc: each change returns a sequence number; commit's number says which changes are in the commit point), :167-170 (completely thread safe), :1677-1688 (tryDeleteDocument javadoc), :1707-1721 (tryUpdateDocValue javadoc, un-delete at :1714-1715), :1791-1827 (tryModifyDocument, synchronized on the writer only; returns docWriter.getNextSequenceNumber() at :1824; no fullFlushLock), :2567-2576 (rollback javadoc), :2750-2801 (deleteAll takes fullFlushLock for 'point in time semantics'), :3730-3732 and :4203-4210 (prepareCommit/commit contract), :3806-3850 (prepareCommitInternal: fullFlushLock, flushAllThreads fixes the commit number at :3809, writeReaderPool(true) at :3827, segmentInfos.clone() at :3850 with the 'sneak into the commit point' comment)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:589 and :680 (flushAllThreads: markForFullFlush swaps the delete queue), DocumentsWriterFlushControl.java:557 (markForFullFlush), DocumentsWriterDeleteQueue.java:616 (advanceQueue: later numbers start above the commit's)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-119 (class javadoc), :449 (afterSegmentsFlushed -> publishFlushedSegments(false)), :1688, :1721, :1694 (delete), :1791-1827 (tryModifyDocument; :1822 bufferedUpdatesStream, :1823 apply, :1824 seqNo), :2953 (publishFlushedSegment, synchronized), :3806-3850 (prepareCommitInternal: fullFlushLock, flushAllThreads, writeReaderPool(true), clone)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:153-165 (applyDeleteOrUpdate), :580 (afterSegmentsFlushed at the end of doFlush), :583-586 (getNextSequenceNumber), :588-598 (resetDeleteQueue), :674-682 (flushAllThreads queue swap)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java:557-575 (markForFullFlush)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:616-630 (advanceQueue)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2567-2571 (rollback javadoc)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:119-121 (sequence numbers transient, valid within one IndexWriter instance)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-119, :167-168, :1680-1681, :1710-1711, :2757, :2764, :3730-3732, :3845-3849, :4203-4210
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:153-165
  • lucene/CHANGES.txt:8925-8927
  • lucene/core/src/test/org/apache/lucene/index/TestIndexingSequenceNumbers.java (no try* call)
Suggested fix
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:113-137, :236-244

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

Repro files

About the diagrams

The summary card (bug.png) is made from a probe run on ea57360912ed with three documents (a and b in the NRT reader's segment, c buffered); the numbers match the repro (8 against 7). Re-rendered after the diagram check: the diagram now shows _1 published at the end of the flush, inside flushAllThreads() (DocumentsWriter.java:580 → IndexWriter.java:449), as a stack-trace probe of the same run shows; the card names the commit's return value s.

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.