prepareCommit() keeps its commit's sequence number in a field shared by the whole writer, runs a merge step outside the commit lock, and then returns the field instead of its own value. If another thread finishes that commit and prepares a new one during the merge step, the field already holds the new commit's number.

prepareCommit() publishes its commit and waits in the merge step; the main thread finishes that commit (3), adds a document (4) and prepares the next (6); A then returns 6. Select the still to open it at full size. Generated with AI, checked against the code.- Add one document (sequence number 1). Thread A calls
prepareCommit(). It publishes the pending commit, then runs its merge step outside the commit lock, so it is still inside the call. - Meanwhile the main thread calls
commit(), which finishes A's prepared commit (1 document) and returns 3. It then adds a document (sequence number 4) and callsprepareCommit()again, which returns 6. - You expect A's
prepareCommit()to return 3: its javadoc says every sequence number <= the result is in the commit it prepared, and no other is. - Instead A's call returns 6, because it re-reads a field shared by the whole writer, which the second
prepareCommit()overwrote. Operation 4 is not in A's commit. No error, with assertions on or off.
Applications that drive one writer's two-phase commit from more than one thread and use prepareCommit()'s number as the exact cut of the commit it prepared, for example to trim their own transaction log. No special codec or setting, no scale. The committed index is correct and nothing throws. Contrived, so priority Low.
Mechanism, terms and numbers548 words
IndexWriter.prepareCommit() stores the sequence number of the commit it prepared in the shared volatile field pendingSeqNo, then runs the post-flush maybeMerge(FULL_FLUSH) outside commitLock, then returns the field again instead of its own value (IndexWriter.java:3735-3743 on main). The prepared commit is already published at that point, so another thread may finish it with commit() (which returns the right number, a local copy), index more and call prepareCommit() again, which overwrites the field. The first call then returns the second commit's number: in the repro 6, while commit() returned 3 for the same commit and operation 4 is not in it. This breaks prepareCommit()'s @return ("All sequence numbers <= this value will be reflected in the commit, and all others will not"). The index contents are correct and nothing throws. It needs one writer's two-phase commit driven from two threads in this interleaving, so it is contrived. The merge step was moved between the store and the re-read by a deadlock fix (LUCENE-7570) first released in 6.4.0; every release since, through 10.6.0, and main are affected. The fix returns the local value.
- Sequence number: every
IndexWritermethod that changes the index returns alongthat orders the change.commit()andprepareCommit()return one too, "describing which changes are in the commit point and which are not" (IndexWriter.java:117-121). - Two-phase commit:
prepareCommit()flushes, syncs and writes most of the nextsegments_N(the commit is now pending);commit()finishes it (:3720-3732).commit()with no pending commit does both phases itself.
// IndexWriter.java:3735-3743
public final long prepareCommit() throws IOException {
ensureOpen();
pendingSeqNo = prepareCommitInternal(); // shared volatile field (:280)
// we must do this outside of the commitLock else we can deadlock:
if (maybeMerge.getAndSet(false)) {
maybeMerge(config.getMergePolicy(), MergeTrigger.FULL_FLUSH, UNBOUNDED_MAX_MERGE_SEGMENTS);
}
return pendingSeqNo; // re-reads the field
}prepareCommitInternal()holdscommitLock(:3775), publishes the pending commit (pendingCommit = toSync,:5984) and returns its number, then releases the lock.prepareCommit()stores the number and runs the merge step with no lock held: the merge policy picks merges and the merge scheduler gets them (:2442-2452).SerialMergeSchedulerruns them on this thread (SerialMergeScheduler.java:32-40);ConcurrentMergeSchedulermay stall this thread while merges are backed up (ConcurrentMergeScheduler.java:566,:609).- Meanwhile another thread's
commit()takescommitLock, sees the pending commit ("commit: already prepared"), takesseqNo = pendingSeqNoand finishes it (:4253-4260); it returns that local copy (:4268). Its nextprepareCommit()is accepted, since no commit is pending now (:3787-3790), and stores the next commit's number in the field. - Thread A leaves the merge step and returns the field (
:3742): the next commit's number.
In the repro: the first addDocument gets 1; the main thread's commit() finishes A's commit (1 document) and returns 3; the next addDocument gets 4; the second prepareCommit() returns 6; A's prepareCommit() returns 6, expected 3. The final commit() returns 6 and holds 2 documents. (Sequence numbers skip values around a full flush; the gaps are normal.)
Related, not claimed (read only; no test targets it, never observed): the field is stored after prepareCommitInternal releases commitLock, so a commit() that takes the lock in that short gap would read the previous value.
Exposure and likelihood283 words
- Who is hit. Only applications that let a second thread finish a commit another thread prepared, and prepare the next one, while the first
prepareCommit()is still in its merge step, and that then trust the first call's number as the cut of that commit. Lucene's own coordinator (TwoPhaseCommitTool) and its threaded tests prepare and commit from one thread or under an application lock. - Default vs opt-in. No special codec or setting is needed: the window is the merge step that
prepareCommit()runs after a flush with changes (IndexWriter.java:3739-3741), with any merge policy and scheduler. It is longest when merges are picked andSerialMergeSchedulerruns them on the caller, orConcurrentMergeSchedulerstalls it. Every executed test that shows the bug held the window open with a latch in a custom merge scheduler (an@lucene.experimentalextension point); a stock configuration was not run, and the window's length in practice was not measured. - Scale. None: two documents.
- How bad. Only the returned
longis wrong. The committed index is correct and nothing throws, with assertions on or off. If thread A then callscommit(), that call finishes the second commit (the one still pending, prepared by the other thread) and returns the same number, so everything up to it becomes durable (executed). So the harm is an over-stated cut for code that pairs the prepare-time number with the earlier commit. By reasoning only (not executed): an application that trims its log up to 6 once A's commit is durable, and crashes before the second commit finishes, has operation 4 neither in the index nor in its log. - Likelihood: contrived. No public report of it was found since 6.4.0 (January 2017).
One JUnit test, two threads (the interleaving is the bug), latches only, no sleeps, deterministic, public API and documented (@lucene.experimental) merge extension points. A merge scheduler holds thread A in prepareCommit()'s merge step while the main thread commits, adds a document and prepares again. Fails with assertions on and off on main and on 10.6.0.
package org.apache.lucene.index;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.FutureTask;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ThreadInterruptedException;
public class TestPrepareCommitOwnSeqNo extends LuceneTestCase {
// Expected: prepareCommit() returns the seqNo that commit() returned for the same commit (3);
// actual: 6, the seqNo of the next commit.
public void testPrepareCommitReturnsItsOwnSeqNo() throws Exception {
CountDownLatch inMerge = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
IndexWriterConfig iwc =
new IndexWriterConfig()
.setMergePolicy(
new FilterMergePolicy(NoMergePolicy.INSTANCE) {
@Override
public MergeSpecification findMerges(
MergeTrigger trigger, SegmentInfos infos, MergeContext context) {
return trigger == MergeTrigger.FULL_FLUSH ? new MergeSpecification() : null;
}
})
.setMergeScheduler(
new MergeScheduler() {
@Override
public void merge(MergeSource source, MergeTrigger trigger) {
if (inMerge.getCount() > 0) {
inMerge.countDown();
try {
release.await();
} catch (InterruptedException e) {
throw new ThreadInterruptedException(e);
}
}
}
});
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
w.addDocument(new Document());
FutureTask<Long> prepare = new FutureTask<>(w::prepareCommit);
new Thread(prepare).start();
inMerge.await();
long committed = w.commit();
w.addDocument(new Document());
w.prepareCommit();
release.countDown();
long prepared = prepare.get();
w.commit();
assertEquals(committed, prepared);
}
}
}
cd $LUCENE
cp $REPRO/TestPrepareCommitOwnSeqNo.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestPrepareCommitOwnSeqNo -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
- Thread A's
prepareCommit()returns 3, whatcommit()returned for the same commit. - Observed
- It returns 6, the next commit's number:
AssertionError: expected:<3> but was:<6>.
More files, environment, full output143 words
The merge policy returns an empty merge specification on the full-flush trigger so that IndexWriter calls the merge scheduler; the scheduler holds the first caller (thread A, inside prepareCommit()) until the main thread releases it. The test draws no random values itself (only the test framework's randomized newDirectory()); every run of it failed (main, 10.6.0, 10.5.2, 9.12.3; 10 of 10 iterations on main). It belongs in lucene/core/src/test/org/apache/lucene/index/.
The test file has no Apache license header, which Lucene's checkLicenses requires; with the header added, the test and either fix pass :lucene:core:check -x test.
Regression test: this same test.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
TestPrepareCommitOwnSeqNo > testPrepareCommitReturnsItsOwnSeqNo FAILED
java.lang.AssertionError: expected:<3> but was:<6>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:EE88E9F533D2A047]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.index.TestPrepareCommitOwnSeqNo.testPrepareCommitReturnsItsOwnSeqNo(TestPrepareCommitOwnSeqNo.java:53)- The
@returnpromises "All sequence numbers <= this value will be reflected in the commit, and all others will not";commit()returns 3 for it. IndexWriteris documented as "completely thread safe";commit()finishes any pending commit, whoever prepared it.- The wide window is an accident of a 6.4.0 deadlock fix (LUCENE-7570) that put the merge step before the re-read.
The full argument277 words
- The contract.
prepareCommit()@return(IndexWriter.java:3730-3732): "The sequence number of the last operation in the commit. All sequence numbers <= this value will be reflected in the commit, and all others will not."commit()has the same@return(:4207-4209). The class javadoc: "IndexWriter instances are completely thread safe, meaning multiple threads can call any of its methods, concurrently" (:167-168). - The trigger breaks no stated rule. The javadoc orders the phases ("After calling this you must call either
commit()to finish the commit, orrollback()to revert the commit",:3723-3725;TwoPhaseCommit.java:36-40) but does not say which thread may finish a prepared commit.commitInternalfinishes whatever commit is pending, without tracking which call or thread prepared it (:4253-4257), and the secondprepareCommit()passes its own "already called" check because the first commit is finished. - History. The first sequence-number patch (LUCENE-7302, #8356, 6.2.0) stored and returned the field on adjacent lines (6.3.0:
pendingSeqNo = prepareCommitInternal(...); return pendingSeqNo;), so a wrong value needed thread A to be preempted between two adjacent field accesses. LUCENE-7570 (#8621, 6.4.0) moved the merge between them to fix a deadlock; neither the issue nor the commit mentions the return value.commit()kept returning a local copy. - Maintainer intent for the number. On java-user@ (2017-08-10, "Storing external transaction log-ids in lucene...") a user planned to let a background commit thread call
prepareCommit()and use its number; Michael McCandless replied "That's exactly right! That is the purpose of the sequence numbers ... You can know exactly which ops made it into your commit" (https://lists.apache.org/thread/75rfkqdby1ln96x0g9s6mmn2yh0n46wp). - Tests. No upstream test compares
prepareCommit()'s return value with a concurrentcommit().
The devil's-advocate check268 words
The strongest case (rated moderate by the cross-model reviews and the devil's-advocate verifier): two-phase commit is meant to have one coordinator. TwoPhaseCommit (@lucene.experimental, TwoPhaseCommit.java:21-41) describes commit() as the phase "following" prepareCommit(), and the prepareCommit() javadoc says to call commit() "after calling this" (IndexWriter.java:3723-3725). Lucene's threaded tests serialize under an application lock, around prepareCommit() plus commit() (TestIndexWriterWithThreads.java:568, :599-608) or around commit() alone (TestIndexingSequenceNumbers.java:166, :187). On LUCENE-1335 (#2412, 2008-08-05) Michael McCandless wrote: "I think we should not make any API promises about what it means when these methods (commit, close, rollback, optimize, addIndexes) are called concurrently from different threads, except that the methods all work correctly, IndexWriter won't throw an errant exception, and your index won't become corrupt." Here the index is correct and nothing throws. On dev@ (2014-12-16, "Transactions multiplexing IndexWriter", https://lists.apache.org/thread/w83vmtn0bbncd4dk3byfnmzrv3r6z0b6) they also wrote that "Lucene's IndexWriter only allows one transaction at a time". A maintainer is likely to cite the 2008 statement.
Why it fails: none of this is a documented precondition. The 2008 statement predates sequence numbers (LUCENE-7302, 2016) by 8 years, and "the methods all work correctly" includes the documented @return. The 2014 remark is about isolating the documents of concurrent transactions, which the repro does not need (the contents are correct). The numbers returned for one commit, 3 from commit() and 6 from prepareCommit(), fit no single-threaded order, which is what sequence numbers promise (CHANGES.txt:8995-8997, LUCENE-7302: "the effective equivalent single-threaded execution order"). What survives is a likelihood argument, hence priority Low; the zero-cost fix makes prepareCommit() consistent with commit().
Recommended Return the local value (one line, recommended)
Return the local value: final long seqNo = prepareCommitInternal(); pendingSeqNo = seqNo; ... return seqNo;, as commit() already does. Validated: the repro passes with assertions on and off, and 7 related index suites (160 tests) and :lucene:core:check -x test pass.
public final long prepareCommit() throws IOException {
ensureOpen();
- pendingSeqNo = prepareCommitInternal();
+ final long seqNo = prepareCommitInternal();
+ pendingSeqNo = seqNo;
// we must do this outside of the commitLock else we can deadlock:
if (maybeMerge.getAndSet(false)) {
maybeMerge(config.getMergePolicy(), MergeTrigger.FULL_FLUSH, UNBOUNDED_MAX_MERGE_SEGMENTS);
}
- return pendingSeqNo;
+ return seqNo;
}All options and trade-offs2 options ยท 472 words
Both options were validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03). The suites: the repro, TestIndexingSequenceNumbers, TestTwoPhaseCommitTool, TestIndexWriterCommit, TestIndexWriterWithThreads, TestTransactions, TestIndexWriter. No workaround inside Lucene is needed for ordinary use: an application that serializes prepare plus commit is not affected, and one that uses the number commit() returns does not hit the failure shown here.
Makes prepareCommit() return what commit() already returns for the same commit.
public final long prepareCommit() throws IOException {
ensureOpen();
- pendingSeqNo = prepareCommitInternal();
+ final long seqNo = prepareCommitInternal();
+ pendingSeqNo = seqNo;
// we must do this outside of the commitLock else we can deadlock:
if (maybeMerge.getAndSet(false)) {
maybeMerge(config.getMergePolicy(), MergeTrigger.FULL_FLUSH, UNBOUNDED_MAX_MERGE_SEGMENTS);
}
- return pendingSeqNo;
+ return seqNo;
}Trade-offs
No other behaviour changes; the field keeps its one reader (IndexWriter.java:4257). It leaves the related gap open (no test targets it, never observed; the field is still stored after commitLock is released).
What the checks validated
- Stock
5adff78bb168: the repro fails through gradle with assertions on and off; with this fix it passes, on and off. - With the fix, 7 suites (the repro,
TestIndexingSequenceNumbers,TestTwoPhaseCommitTool,TestIndexWriterCommit,TestIndexWriterWithThreads,TestTransactions,TestIndexWriter): 160 tests, 0 failures, 8 skipped. :lucene:core:check -x test(license headers, format, lint, forbidden APIs) with the fix and the test with its license header: passes.- Earlier, the hunt's shadow-class runs of the same change passed the minimal repro (on, off), the finder's, the independent reproducer's and the cross-model reviewers' tests.
In prepareCommitInternal, set pendingSeqNo = seqNo right after startCommit published the pending commit, still inside commitLock; prepareCommit() returns its local value. The field and the pending commit are then written in the same locked section, and the field is read only under that lock.
@@ prepareCommit()
- pendingSeqNo = prepareCommitInternal();
+ final long seqNo = prepareCommitInternal();
...
- return pendingSeqNo;
+ return seqNo;
@@ prepareCommitInternal(), after startCommit(toCommit)
if (pendingCommit == null) {
return -1;
} else {
+ // publish the pending commit's seqNo while still holding commitLock
+ pendingSeqNo = seqNo;
return seqNo;
}Trade-offs
Also closes the store-after-unlock gap, but no test targets that gap, so this extra benefit is untested. Touches prepareCommitInternal, which commit() also calls when nothing is prepared; the store is harmless there, since the field is read only while a commit is pending.
What the checks validated
- With this fix the repro passes through gradle, on and off; the same 7 suites: 160 tests, 0 failures, 8 skipped;
:lucene:core:check -x testwith the license-headed test: passes. - Its extra benefit, closing the store-after-unlock gap, has no test: no test targets that gap.
Downstream products are exposed only if they use prepareCommit()'s number as a commit cut while more than one thread drives the same writer's two-phase commit; no query, document or index file can trigger it on its own (documents only change how long the merge step lasts).
A proof-obligation audit of IndexWriter.prepareCommit()'s contract asked whether the returned number is always the cut of the commit the call prepared, and found the re-read of the shared field after the unlocked merge step. A public-API JUnit test then witnessed it on main; independent checks reproduced it with their own tests.
The method in full137 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The obligation: the value
prepareCommit()returns satisfies its@returnfor the commit this call prepared, under the class's "completely thread safe" promise. Between the store (:3737) and the return (:3742) no lock is held and another thread can publish a new value, so the obligation is not met. - A JUnit test that holds the preparing thread in its merge step with a latch witnessed it on main (assertions on and off; 10 of 10 iterations) and on 10.6.0 and 9.12.3. An independent reproducer, two executed reviews by another model family (OpenAI GPT-5.6) and a devil's-advocate verifier wrote their own tests from the claim; the independent reproducer's test, as cut down in a final repro check, is the repro here.
No prior report: two scripted prior-art runs and 30 manual searches over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web, plus the changed files of all 408 open PRs (2026-11-02). pendingSeqNo has 0 hits in issues and PRs.
Searches3 searches
No report found in the documented searches says that prepareCommit() re-reads pendingSeqNo after its merge step or can return a later commit's number. Related, not this bug: #8356 (LUCENE-7302, introduced sequence numbers and the @return), #8621 (LUCENE-7570, opened the window; its discussion is only about the deadlock), #5219 (LUCENE-4147, a rollback/prepareCommit race from 2012, before sequence numbers), #4189 (LUCENE-3116, open, pendingCommit test coverage), #13571 (a delete-queue sequence-number race, other code). Upstream main 1c2072f0284 (2026-11-02) still re-reads the field. Gaps: keyword search; GitHub search reads one page per query.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA archive; web | 2026-11-02 | two scripted prior-report search runs (3348 and 2458 distinct hits), 30 manual GitHub searches, 3 JIRA queries, 8 web searches | only the related items above |
| Open PRs | 2026-11-02 | the changed files of all 408 open apache/lucene PRs; patches of the 18 that touch IndexWriter.java | none changes prepareCommit, pendingSeqNo, commitInternal or pendingCommit |
| Recheck (GitHub) | 2026-11-02 | 'pendingSeqNo'; prepareCommit items created since 2026-10-31 or updated since 2026-11-01 | 0 hits |
LUCENE-7570 (2b073a2f296, ea3f8363319, 2016-12-09), a deadlock fix, moved the merge step between the store and the re-read; first released in 6.4.0 (January 2017). The re-read dates from 6.2.0, where it directly followed the store (latent, not reproduced). Executed: 6.4.0 to 10.6.0 and main reproduce; the parent commits, 6.2.0 and 6.3.0 do not. Not fixed upstream.
Versions tested8 rows
Window opened by LUCENE-7570 (#8621, "IndexWriter may deadlock if a commit is running while there are too many merges running and one of the merges hits a tragic exception"), committed by Mike McCandless on 2016-12-09 as ea3f8363319 (branch_6x) and 2b073a2f296 (master): "don't run merges while holding the commitLock to prevent deadlock when merges are stalled and a tragic merge exception strikes". A JIRA patch, no pull request. The return pendingSeqNo; line is older (LUCENE-7302, 058970e72bd, 2016-05-24, 6.2.0): there the store and the re-read were adjacent, after prepareCommitInternal had released commitLock (6.2.0 and 6.3.0 IndexWriter.java:2936-2937), so a wrong value would need thread A to be preempted between two adjacent field accesses while another thread commits and prepares again. No hook reaches that point; it was not reproduced and is not claimed. Source builds of both LUCENE-7570 commits reproduce; in their parents commit() blocks on commitLock until thread A returns, so the test's window is closed. The releases between those executed contain the change (git merge-base --is-ancestor over the 78 release tags from 6.4.0 to 10.6.0). Upstream main 1c2072f0284 (2026-11-02) is unchanged here. All runs used JDK 25.0.3; the commit-level jars were source builds.
| Version or commit | Verdict | Result |
|---|---|---|
| 6.1.0 and earlier | no API | no sequence numbers: prepareCommit() returns void (executed on 6.1.0; older by reading) |
| 6.2.0, 6.3.0 | latent, not reproduced | the same re-read, directly after the store; the merge ran under commitLock, so the test's commit() waits for thread A |
| branch_6x 72537fd2cd8, master 7cffae3a16f (parents) | latent, not reproduced | the test's window is closed (commit() blocked on commitLock); the re-read directly follows the store |
| branch_6x ea3f8363319, master 2b073a2f296 (LUCENE-7570) | introduced | the history probe reproduces |
| 6.4.0 | first: 6.4.0 | the history probe reproduces (assertions on and off) |
| 6.6.6, 7.0.0, 7.7.3, 8.5.2, 8.6.0, 8.11.4 | affected | the history probe reproduces |
| 9.12.3, 10.5.2, 10.6.0 | affected | the one-test repro fails (assertions off; 10.6.0 also in this doc's runs and its fact check) |
| main 5adff78bb168 | affected | the one-test repro fails through gradle, assertions on and off; passes with either fix |
An independent reproducer, two executed reviews by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (medium confidence, priority Low). This doc's gradle runs reproduced it on main and validated both fixes.
Checks and verdicts342 words
The cross-model reviews ran on main ea57360912ed, whose IndexWriter.java differs from 5adff78bb168 only in one log message (#16963); both reviewers' own tests were re-run on 5adff78bb168 with the same result. Every other check ran on 5adff78bb168 or a release.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Own test with a real merge: fails on main (on, off; 10 of 10), 10.6.0 and 9.12.3; the local-return fix passes it. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own test with a paused SerialMergeScheduler: fails on and off; its control passes; the fix passes. |
| independent reproducer | real, Low | Own test from the claim only: fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; the fix passes it. Basis of the repro here. |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Own test fails on and off and on 9.12.3; a sequential control passes; the one-coordinator argument rated moderate and rejected. |
| devil's-advocate verifier | real, medium | Steelmanned the one-transaction-at-a-time reading and rebutted it; its probe shows A's own commit() returns the same number, so the harm is narrow. |
| adjudication | confirmed (medium); Low | Re-ran both tests on stock main (fail) and the reproducer's test with the fix (pass); not a duplicate. |
| separate re-run, audit and final repro check | reproduced; confirmed, Low | Fresh seeds fail on and off; the one-test repro fails on and off and passes with the fix. |
| this doc | reproduced, both fixes validated | Gradle on 5adff78bb168 (fail on and off; pass with either fix; 7 suites pass); 10.6.0 jars (fail); a probe printed every number. |
| fact checks of this doc (one by another model family, OpenAI GPT-5.6) | reproduced; wording corrected | Re-ran the gradle command (fails on and off; passes with the recommended fix), 10 of 10 iterations on main and the shown test on 10.6.0, 10.5.2 and 9.12.3 (all fail). Corrections taken in: the 6.4.0 release year, the latent 6.2.0-6.3.0 form, the tests' locking, and the 2008 maintainer statement in the strongest case for intended behaviour. |
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 456 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,435 words
- Registry title
- IndexWriter.prepareCommit() re-reads the shared pendingSeqNo field after its post-commit maybeMerge, so if another thread finishes that commit and prepares the next one meanwhile, prepareCommit() returns the next commit's sequence number
- Area
- index / IndexWriter two-phase commit (sequence numbers)
- Priority
- Low, rank 937: A prepareCommit() call can return the next commit's sequence number while the committed index stays correct, in every release since 6.4.0. It needs two threads driving one writer's expert two-phase commit in a specific interleaving and a caller that trusts that number as the commit's exact cut, so it is contrived.
- Impact class
- silent wrong results
- Impact note
- Only applications that drive IndexWriter's expert two-phase commit from more than one thread and use prepareCommit()'s returned number as the exact cut of the commit it prepared (for example to checkpoint a transaction log) are hit, and only when another thread finishes that pending commit with commit() and calls prepareCommit() again while the first prepareCommit() is still inside the maybeMerge(FULL_FLUSH) it runs outside commitLock (a window that is long with SerialMergeScheduler or when ConcurrentMergeScheduler stalls the caller): the commit contents are correct and nothing throws (-ea and -da alike), only the returned long is too high; default codec and config suffice, no scale is needed, and every release from 6.4.0 through 10.6.0 and main are affected.
- Summary
- The main thread adds a document and thread A calls IndexWriter.prepareCommit(); while A is still in the merge step that prepareCommit() runs after publishing its pending commit, the main thread calls commit() (which finishes A's commit and returns 3), adds another document (sequence number 4) and calls prepareCommit() again. You expect A's prepareCommit() to return 3, as its javadoc promises ('All sequence numbers <= this value will be reflected in the commit, and all others will not') and as commit() returned for the same commit. Instead it returns 6, the next commit's number, because prepareCommit() re-reads the shared pendingSeqNo field after its maybeMerge instead of returning its own result; the committed index itself is correct.
- Introduced
- ea3f8363319955c589eb3a7df59a031621852d3e (branch_6x, first released in 6.4.0) and 2b073a2f296289617bea8256d7efec06049df739 (master, first released in 7.0.0), both 'LUCENE-7570: don't run merges while holding the commitLock to prevent deadlock when merges are stalled and a tragic merge exception strikes' (mikemccand; LUCENE-7570 = apache/lucene#8621, a JIRA patch, no pull request): it moved the post-flush maybeMerge(FULL_FLUSH) out of prepareCommitInternal's commitLock into prepareCommit, between 'pendingSeqNo = prepareCommitInternal()' and 'return pendingSeqNo' [exec: commit-level A/B on both lines; each parent closes the window (commit() blocks on commitLock until thread A's prepareCommit returns), each LUCENE-7570 commit reproduces]. The re-read line itself dates from the sequence-numbers patch (LUCENE-7302 = #8356, 058970e72bd on master / 88dd1aad774 on branch_6x, 2016-05-24, first in 6.2.0), but there nothing ran between the store and the re-read. (2016-12-09 (author and commit date of 2b073a2f296 and ea3f8363319))
- First affected version
- 6.4.0 [exec: the history probe reproduces on 6.4.0 with -ea and -da; 6.2.0 and 6.3.0 have sequence numbers but ran the merge under commitLock, so the window is closed; 6.1.0 and older have no sequence numbers]
- Affected versions
- Every release from 6.4.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed and reproduced with the history probe: 6.4.0 (-ea, -da), 6.6.6, 7.0.0, 7.7.3, 8.5.2, 8.6.0, 8.11.4, 9.12.3, 10.6.0 (-ea, -da) and main (-ea, -da), plus the two LUCENE-7570 commit builds [exec]; the one-test repro fails on main (-ea, -da), 10.6.0, 10.5.2 and 9.12.3 [exec]; the releases in between contain the change [read: git merge-base --is-ancestor over the 78 release tags from 6.4.0 to 10.6.0 in the history clone]. Latent, not reproduced: 6.2.0 and 6.3.0 (the re-read directly follows the store there and the merge runs under commitLock, so the test's window is closed) [exec]. Not affected: 6.1.0 and older (API absent: prepareCommit, commit and addDocument return void) [exec 6.1.0]. Not new in 10.6. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still re-reads the field [read].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: proof-obligation audit
- Upstream and prior reports
- none found (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches): two scripted prior-report searches (32 GitHub issue and PR queries on apache/lucene, 3 commit queries, 2 downstream-repository queries, 40 mailing-list queries over dev@, java-user@, issues@ and commits@, 8 JIRA-archive queries, 6 web queries; 3348 and 2458 distinct hits, none about the returned number), 30 manual GitHub searches (incl. 'pendingSeqNo', prepareCommit with seqNo or 'sequence number', Elasticsearch, OpenSearch and Solr, items created or updated since 2026-10-31), a scan of the changed files of all 408 open apache/lucene PRs (18 touch IndexWriter.java; none of their patches changes prepareCommit, pendingSeqNo, commitInternal or pendingCommit), 3 JIRA JQL queries, 3 list threads read in full and 8 general web searches. Nearest, not the same bug: #8356 (LUCENE-7302, introduced sequence numbers and the @return contract), #8621 (LUCENE-7570, the deadlock fix that introduced the re-read window; its discussion never mentions the return value), #5219 (LUCENE-4147, rollback vs prepareCommit, 2012, before sequence numbers), open #4189 (LUCENE-3116, pendingCommit test coverage), #13571 (a delete-queue sequence-number race, other code). Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still has 'return pendingSeqNo;' after maybeMerge; the only upstream commit to IndexWriter.java since 2026-10-25 by author date is 5adff78bb16 (#16963), which does not touch prepareCommit. Re-check (2026-11-02): GitHub search 'pendingSeqNo' 0 hits, prepareCommit items created since 2026-10-31 0, updated since 2026-11-01 0.
- Lucene commit tested
- main
5adff78bb168(the one-test repro through gradle in a clean scratch worktree, assertions on and off, unfixed and with each of the two fixes; 7 relatedorg.apache.lucene.indexsuites with each fix, 2026-11-03); the same test as a standalone JUnit run on a plain class path, on main (on and off) and on the 10.6.0 release jars (off). Earlier checks ran the same test or its earlier version on main5adff78bb168, 10.6.0, 10.5.2 and 9.12.3 (the cross-model reviews onea57360912ed, whoseIndexWriter.javadiffers only in one log message, then re-run on5adff78bb168), and a history probe on releases from 6.1.0 to 10.6.0 and on source builds of the introducing commits and their parents.
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3735-3743 (at 5adff78bb168) (prepareCommit: :3737 'pendingSeqNo = prepareCommitInternal();', :3739-3741 maybeMerge(FULL_FLUSH) outside commitLock, :3742 'return pendingSeqNo;' re-reads the shared field; fix site)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3720-3732 (at 5adff78bb168) (prepareCommit javadoc; @return at :3730-3732: 'All sequence numbers <= this value will be reflected in the commit, and all others will not')lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:280 (at 5adff78bb168) ('private volatile long pendingSeqNo'), :167-168 (class javadoc: 'completely thread safe, meaning multiple threads can call any of its methods, concurrently'), :115-121 (sequence-number section)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:4233-4268 (at 5adff78bb168) (commitInternal: :4257 'seqNo = pendingSeqNo' in the 'already prepared' branch under commitLock; returns its local seqNo after its own maybeMerge)lucene/core/src/java/org/apache/lucene/index/TwoPhaseCommit.java:21-41 (at 5adff78bb168) (@lucene.experimental; prepareCommit is the first stage, commit the second; basis of the intended-behaviour counter-argument)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3735-3743 (at 5adff78bb168) (prepareCommit), :280 (pendingSeqNo), :117-121 (sequence numbers), :3720-3732 (javadoc)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3773-3790, (at 5adff78bb168) :5984 (prepareCommitInternal under commitLock; pendingCommit published in startCommit)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2442-2452 (at 5adff78bb168) (maybeMerge, executeMerge); :4233-4268 (commitInternal, 'already prepared' branch :4257)lucene/core/src/java/org/apache/lucene/index/SerialMergeScheduler.java:32-40; (at 5adff78bb168) ConcurrentMergeScheduler.java:542-566, :599-620
- Who is affected
lucene/core/src/test/org/apache/lucene/index/TestIndexWriterWithThreads.java:568, (at 5adff78bb168) :599-608 (lock around prepareCommit() plus commit()); TestIndexingSequenceNumbers.java:166, :187 (lock around commit() only)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-121, (at 5adff78bb168) :167-168, :3720-3732 (:3723-3725 'After calling this you must call either commit() ... or rollback()'), :3787-3790, :4207-4209, :4253-4257lucene/core/src/java/org/apache/lucene/index/TwoPhaseCommit.java:21-41 (at 5adff78bb168) (:36-40 commit() as the phase 'following' prepareCommit())lucene/CHANGES.txt:8741-8743 (at 5adff78bb168) (LUCENE-7570, 6.4.0), :8995-8997 (LUCENE-7302, 6.2.0)a Lucene clone releases/lucene-solr/6.3.0 and 6.4.0 IndexWriter.java prepareCommit (adjacent store and return vs merge in between)lucene/core/src/test/org/apache/lucene/index/TestIndexWriterWithThreads.java:568, (at 5adff78bb168) :599-608 (lock around prepareCommit() plus commit()); TestIndexingSequenceNumbers.java:166, :187 (lock around commit() only; no prepareCommit() call)GitHub apache/lucene#2412 (LUCENE-1335, 'Correctly handle concurrent calls to addIndexes, optimize, commit'), comment by Michael McCandless migrated from JIRA, 2008-08-05 (the 'no API promises' quote); read on GitHub, 2026-11-03
- History
a Lucene clone releases/lucene-solr/6.3.0 and 6.4.0 IndexWriter.java prepareCommitLucene main 5adff78bb168 dev-tools/doap/lucene.rdf:674-676 (lucene-6.4.0 created 2017-01-23); a Lucene clone tag releases/lucene-solr/6.4.0 (2017-01-20)
- Diagrams
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3735-3743 (at 5adff78bb168)
- TestPrepareCommitOwnSeqNo.java (one test, lucene/core):
repro/LFV-1127/TestPrepareCommitOwnSeqNo.java(56 lines)
Made in parallel with this doc from the code at 5adff78bb168 and the executed repro's numbers; the PNG is the summary still.
assets/LFV-1127/bug.png(456 KB)
