Every IndexWriter change returns a sequence number, and commit() returns the number that splits all changes into "in this commit" and "not in it".
- A writer holds document
a(v=1), visible in an NRT readernrt, plus one newly buffered document. Thread 1 callscommit(); it fixes its sequence number and starts writing the buffered document's segment. - Meanwhile thread 2 calls
tryDeleteDocument(nrt, a), ortryUpdateDocValue(nrt, a, v=42). The call returns 8; the commit returns 7. - You expect commit 7 to leave
aalone: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. - Instead commit 7 already has
adeleted, 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. - No error,
CheckIndexis clean in the rollback probe, androllback()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.
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.
- Sequence number (seqNo): every
IndexWritermethod that changes the index returns one. The class javadoc says it "expresses the effective order in which each change was applied", andcommit()returns one "describing which changes are in the commit point and which are not" (IndexWriter.java:117-119). - try* methods:
tryDeleteDocument(reader, docID)andtryUpdateDocValue(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.tryDeleteDocumentalso returns a number when the document was already deleted. ThetryUpdateDocValuejavadoc says a null value can be used to un-delete a soft-deleted document. - Full flush: what
commit()(and an NRT reopen, andflush()) 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.
| operation run while the commit writes its segment | its seqNo | commit seqNo | in the commit? | contract |
|---|---|---|---|---|
deleteDocuments(Term) | 8 | 7 | no | holds |
deleteDocuments(Query) | 8 | 7 | no | holds |
updateNumericDocValue(Term) | 8 | 7 | no | holds |
tryDeleteDocument(nrt, a) | 8 | 7 | yes | broken |
tryUpdateDocValue(nrt, a, v=42) | 8 | 7 | yes (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 commit fixes its number and swaps the queue first.
prepareCommitInternaltakesfullFlushLock(IndexWriter.java:3806) and callsflushAllThreads()(:3809). Under theDocumentsWritermonitor (DocumentsWriter.java:674-682) this runsmarkForFullFlush(DocumentsWriterFlushControl.java:557-575):fullFlush = true, andresetDeleteQueue(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). - 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;:3819publishes any leftover), then applies deletes (:3825). It holdsfullFlushLockthroughout, and the writer's monitor only briefly to publish each segment (:2953), until it takes it for the snapshot at:3826. - A try* call runs in that gap. Both methods are
synchronizedon the writer (:1688,:1721) and sharetryModifyDocument(:1791-1827). It applies the change directly to the pooled segment reader (:1823) and returnsdocWriter.getNextSequenceNumber()(:1824), which now comes from the new queue: 8, above the commit's 7. It never takesfullFlushLockand never checks whether a full flush is running, so any try* call that returns after the queue swap and before:3826is caught. - The commit then snapshots the change. Back under the writer's monitor (
:3826) it callswriteReaderPool(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 anupdateDocument) cannot "sneak into the commit point" (:3845-3849). - 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
tryDeleteDocumentortryUpdateDocValuewhile another thread runscommit()orprepareCommit(), 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 withrollback(), 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 oneIndexWriterinstance (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(), atryDeleteDocumentnumbered 9 above a commit numbered 8 is still applied, so a document the application believes live is gone; atryUpdateDocValueun-delete of a soft-deleted document (9 against 8) survives, so the document is visible to searches. Before/after controls behave;CheckIndexis 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
CheckIndexfound 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 exampleAsserting(Lucene104)). The repro only setsNoMergePolicy, 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 failstryDeleteDocumentonly 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; notryUpdateDocValuecaller 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.
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).
/*
* 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);
}
}
# 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>.
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)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.
deleteAlltakesfullFlushLockfor "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 forprepareCommit()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".IndexWriteris "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.
TestIndexingSequenceNumberschecks 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
tryDeleteDocumentfrombooleaninto 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 fixinggetMaxCompletedSequenceNumber) 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";deleteAlltakesfullFlushLockfor "point in time semantics" (IndexWriter.java:2757,:2764);prepareCommitInternalclones while it holdsfullFlushLockand the monitor so that partial compound changes cannot "sneak into the commit point" (:3845-3849; a comment about operations such asupdateDocument, 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.
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:TestSoftDeletesRetentionMergePolicycallstryDeleteDocumentfrom 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
synchronizedmodifier; 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(includesTestIndexRearranger): 315 tests, 9 skipped, 0 failures. The 18 closest core suites also with assertions off: 359 tests, 0 failures.:lucene:core:check -x testpasses. - 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.
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
tryUpdateDocValuejavadoc 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.
IndexRearrangertreats -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
TestIndexRearrangerpass. The full suites were not run with A.
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.
The direction of the open #7444 ("remove or fix IndexWriter.tryDeleteDocument").
Trade-offs
Larger API change; maintainers may prefer to answer there. Not tested.
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.
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) addedtryDeleteDocumentas an action that takes its number from the current queue under the writer's monitor only. With configurationMC_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#createOutputhook), which failed with 8 against 7. Independent checks followed (see Checks), and this doc added the minimal five-test version and the fix validation.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, the archived lucene-solr) | 2026-10-26 | a 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 full | related 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 PRs | 2026-10-26 | changed-file lists of all 409 open apache/lucene PRs; the 24 touching IndexWriter or DocumentsWriter* grepped | only #16847 (an open draft PR) edits tryModifyDocument (docID int to long) |
| Commits and upstream state | 2026-10-26 | 9 commit queries; upstream main 49483e82b357 compared with the tested tree | IndexWriter.java byte-identical; no CHANGES entry |
| Downstream (Elasticsearch, OpenSearch, Solr) and all of GitHub | 2026-10-26 | 6 tool queries and 17 GraphQL searches | no 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-26 | 60 queries; 5 threads (23 mails) read | no report; java-user@ 2016 and 2017 and dev@ 2023 restate the contract (intent evidence) |
| JIRA archive and web | 2026-10-26 | 12 archive and 8 JQL queries; 18 web queries | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 6.0.0, 6.0.1, 6.1.0 | no seqNos | the 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) | affected | tryDeleteDocument 13 against commit 12, in the commit; controls pass |
| 8.6.0 - 10.5.2 (49 releases) | affected | tryDeleteDocument 9 against 8, in the commit; controls pass |
| 7.0.0 - 7.3.1 | no tryUpdateDocValue | the tryUpdateDocValue probe does not compile |
| 7.4.0 - 10.5.2 (67 releases) | affected | tryUpdateDocValue above the commit, in the commit; controls pass |
| 9.12.3, 10.5.2 | affected | this doc's test: 2 of 5 fail (8 against 7), the 3 controls pass |
main ea57360912ed | affected | 2 of 5 fail with assertions on and off (gradle and harness); 40 of 40 try* runs over 20 seeds |
main a9779ef3c193 (2026-10-26) | affected | the finder's test fails 2 of 2 |
88dd1aad774 (parent of 7ee1f422458) | bisect | cannot express the scenario: tryDeleteDocument still returns boolean |
7ee1f422458 (LUCENE-7302, branch_6x) | bisect | tryDeleteDocument 7 against 6, in the commit; controls pass |
b1fb142af00 (merge to master) | bisect | 13 against 12 |
0600a58f005 / 48a2138e899 (LUCENE-8297) | bisect | parent has no tryUpdateDocValue; the commit fails 13 against 12 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+/TLC, then JUnit) | candidate | Two 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 | real | Own 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, medium | Rated 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, high | Built 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, high | Re-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 security | 199 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 | keep | Fresh 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 validated | The 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, fixed | Re-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-rendered | Every 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 date | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 478 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,250 words
- 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 maina9779ef3c193(2026-10-26); fix options A and B in a scratch worktree ofea57360912ed(B through all of:lucene:core:test, all of:lucene:misc:testand:lucene:core:check -x test; A through the 18 closest suites andTestIndexRearranger), removed afterwards.
- 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-4210lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:153-165lucene/CHANGES.txt:8925-8927lucene/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
- TestTryDeleteDocumentDuringCommit.java (JUnit test):
repro/LFV-471/src/org/apache/lucene/index/TestTryDeleteDocumentDuringCommit.java(84 lines)
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.
assets/LFV-471/bug.png(478 KB)
![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).](../assets/LFV-471/bug.png)