addIndexes, the delete packet that reaches the copied segment, and expected versus actual values, then the same gap in commit(). Select the still to open it at full size. Generated with AI, checked against the code.- Writer
wholds one committed documentid:k, val=1; a second indexsrcholdsid:k, val=100. Thread A callsw.addIndexes(src); before it publishes, thread B callsw.deleteDocuments(id:k). - The delete returns sequence number 5 and
addIndexesreturns 6. By theIndexWriterjavadoc these numbers give "the effective order in which each change was applied": the delete came first. - You expect the delete to remove only
val=1: an NRT reader should show live values[100], as it does when the two calls run one after the other. - Instead it shows
[]: the added segment keeps delete generation -1, so the buffered delete applies to it too.updateDocumentgives[2], not[2, 100]; a doc-values update[7, 7], not[7, 100]; acommit()that returns 7 holdsval=100from theaddIndexesthat returns 8. - No exception or assertion, even with
-ea, and the next commit persists the result. Default settings suffice (new IndexWriterConfig(), default codec,FSDirectory); all releases 6.2.0-10.5.2 and main show it.
Applications that call addIndexes on a live writer while other threads commit, or delete or update documents whose terms match incoming ones, and that rely on sequence numbers: to tell which update won, or to replay only the operations after a commit. It needs that overlap; the window grows with the size of the incoming index.
Mechanism, terms and numbers809 words
Call addIndexes while another thread deletes or updates a document whose term also matches an incoming document. The delete returns sequence number 5 and addIndexes returns 6, so by the IndexWriter contract the delete came first and must not touch the added document. It does: in the repro the added document (val=100) is gone at the next refresh, live values [] instead of [100]. With updateDocument the added document is lost, with a doc-values update it is overwritten, and a commit() that returns 7 can contain an addIndexes that returns 8. No error is raised. Default settings suffice (executed with a plain new IndexWriterConfig(), the default codec and FSDirectory); it needs concurrent calls, and every release since sequence numbers were added (6.2.0) has it.
IndexWriter#addIndexes copies segments from other indexes (addIndexes(Directory...)) or merges readers (addIndexes(CodecReader...)) into a live writer. IndexWriter is documented as "completely thread safe", so other threads may keep deleting, updating and committing meanwhile.
Every call that changes the index returns a sequence number (seqNo). The class javadoc says it "expresses the effective order in which each change was applied", and commit() promises that "all sequence numbers <= this value will be reflected in the commit, and all others will not". So two overlapping calls still get a definite order: the smaller number came first.
The writer w holds one committed document id:k, val=1; a second index src holds one document id:k, val=100.
| step | thread | call | returns | what you expect |
|---|---|---|---|---|
| 1 | A | w.addIndexes(src) starts (the repro parks it after its up-front flush) | ||
| 2 | B | w.deleteDocuments(id:k) before A publishes | seqNo 5 | val=1 is deleted |
| 3 | A | addIndexes finishes | seqNo 6 | val=100 arrives after the delete |
| 4 | B | open an NRT reader | live values [100] |
What you get: []. The delete also removed the document that, by the sequence numbers, was added after it. Run the two calls one after the other and you get [100].
The same window with other calls (all with seqNo 5 against addIndexes 6): updateDocument(id:k, val=2) gives [2] instead of [2, 100] (the added document is lost); updateNumericDocValue(id:k, "val", 7) gives [7, 7] instead of [7, 100] (the added document is rewritten); addIndexes(CodecReader...) gives [] too. And a commit() that runs while addIndexes is copying returns 7 while addIndexes returns 8, yet the commit point holds [1, 2, 100]: it contains a change whose sequence number says it is not in it.
Inside the writer, deletes do not follow sequence numbers. A delete by term waits in a buffer and is later frozen into a packet with a delete generation; a packet applies to every segment whose own generation is not newer. addIndexes takes its place in the first order but never in the second:
- It flushes only at the start.
addIndexesbegins withflush(false, true)(IndexWriter.java:3119), which keeps deletes buffered before the call off the incoming documents (the 2011 fix LUCENE-2996). Then it copies the files without holding theIndexWritermonitor (:3121-3192;copySegmentAsIs,:3648-3705). The copies go into a local list, not yet into the writer's segment listsegmentInfos(:3183). - The delete during the copy waits in the buffer.
deleteDocumentsappends it to the delete queue and returns seqNo 5 (:1864-1867->DocumentsWriterDeleteQueue.java:129-133). Nothing is applied yet. addIndexespublishes with a later seqNo but no generation. Under theIndexWritermonitor only, it takesdocWriter.getNextSequenceNumber()= 6 and appends the segments (:3194-3211; CodecReader:3350-3369). The new segments keepbufferedDeletesGen = -1, the default (SegmentCommitInfo.java:88); only flushed segments (IndexWriter.java:2989) and merged segments (:4554) get one, so the added segments keep -1 until a merge replaces them.- At the next refresh the delete reaches the added segment. The buffered delete is frozen into a packet with the next generation (
BufferedUpdatesStream.java:75), and a packet applies to every segment withbufferedDeletesGen <= delGen(IndexWriter.java:6784; the skip test for term deletes isFrozenBufferedUpdates.java:460). In a trace of the repro the old segment has generation 1, the added segment -1, and the delete packet 2: it applies to both. - Commit cuts at a full flush that
addIndexesignores.prepareCommitholdsfullFlushLockfrom the full flush that fixes its seqNo (:3806-3809) until it clones the segment list (:3850).addIndexespublishes without taking that lock (only its up-front flush holds it,:4400), so it can publish in between: its seqNo (8) comes after the cut (7), its segments are in the clone.
Sequence numbers say "delete, then addIndexes"; delete generations say "addIndexes, then delete"; the generations decide. The index stays consistent with that second order, so nothing looks broken, except that it contradicts the numbers IndexWriter returned.
Exposure and likelihood305 words
- Who is hit. Applications that call
addIndexeson a live writer while other threads delete or update documents whose terms can match incoming documents, or while another thread commits, and that rely on sequence numbers. LUCENE-7302 added them for two stated uses: telling "which update 'won'" without per-id locking, and crash recovery that replays "only the truly missing operations" after a commit's sequence number. Here the seqNos sayaddIndexes(6) came afterupdateDocument(5) while the index kept the update; and a replay of everything after commit 7 re-runsaddIndexes(8), whose documents are already in that commit, so they are added twice (reasoning from the code and the stated use, not executed). - Default settings. Default settings suffice: a plain
main()program without the test framework, withnew IndexWriterConfig()(only anInfoStreamadded for the hooks; default codec,TieredMergePolicy,ConcurrentMergeScheduler,commitOnClose=true) andFSDirectory, shows all three violations with assertions on and off, and passes under fix B. Both public overloads, public API only. The JUnit repro setsNoMergePolicyandSerialMergeScheduleronly to make its thread hooks deterministic; the study's stress harness hit it under randomized configurations. - It needs concurrency. An
addIndexesoverlapping a matching delete or update, or a commit. The window is the whole copy or merge, so it grows with the size of the incoming index. Under plain stress the study's harness hit it in 9 of 100 seeds in 10 s. - Silent and durable. No exception, no assertion under
-ea; the next commit persists the deletion or overwrite. The index is internally consistent, it just follows the opposite order. - Pattern in use. Merging per-group writers into a live writer with
addIndexesis discussed upstream (#13387) and in a downstream design (OpenSearch#18576 / #19530) that applies deletes around that step. Whether that code is exposed was not checked.
The test shows that a deleteDocuments call that runs on another thread while addIndexes(Directory...) copies its source returns a smaller sequence number than addIndexes, yet it deletes the document that addIndexes added.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.util.concurrent.FutureTask;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.InfoStream;
public class TestAddIndexesConcurrentDeleteSeqNo extends LuceneTestCase {
public void testDeleteWithSmallerSeqNoDoesNotDeleteAddedDocument() throws Exception {
try (Directory src = new ByteBuffersDirectory();
Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter srcWriter = new IndexWriter(src, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new StringField("id", "k", Field.Store.NO));
srcWriter.addDocument(doc);
}
IndexWriter[] writer = new IndexWriter[1];
FutureTask<Long> delete =
new FutureTask<>(() -> writer[0].deleteDocuments(new Term("id", "k")));
InfoStream runDeleteWhileAddIndexesCopies =
new InfoStream() {
@Override
public void message(String component, String message) {
if (message.startsWith("addIndexes: process directory")) {
Thread other = new Thread(delete);
other.start();
try {
other.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
@Override
public boolean isEnabled(String component) {
return component.equals("IW");
}
@Override
public void close() {}
};
IndexWriterConfig iwc = new IndexWriterConfig().setInfoStream(runDeleteWhileAddIndexesCopies);
try (IndexWriter w = new IndexWriter(dir, iwc)) {
writer[0] = w;
long addIndexesSeqNo = w.addIndexes(src);
long deleteSeqNo = delete.resultNow();
assertTrue(deleteSeqNo + " < " + addIndexesSeqNo, deleteSeqNo < addIndexesSeqNo);
try (DirectoryReader reader = DirectoryReader.open(w)) {
// expected: the earlier delete leaves the added document live; actual: it deletes it
String ops = "delete seqNo " + deleteSeqNo + ", addIndexes seqNo " + addIndexesSeqNo;
assertEquals("live docs after " + ops, 1, reader.numDocs());
}
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-014/, 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.TestAddIndexesConcurrentDeleteSeqNo
- Expected vs actual
- Expected: delete seqNo 2 < addIndexes seqNo 3, so the added document stays live (1 live doc). Actual (-ea and -da): 0 live docs.
More files, environment, full output42 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: live docs after delete seqNo 2, addIndexes seqNo 3 expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.index.TestAddIndexesConcurrentDeleteSeqNo.testDeleteWithSmallerSeqNoDoesNotDeleteAddedDocument(TestAddIndexesConcurrentDeleteSeqNo.java:75)- Javadoc: a sequence number "expresses the effective order in which each change was applied"; for
commit(), "all sequence numbers <= this value will be reflected in the commit, and all others will not". IndexWriteris "completely thread safe" with no exception foraddIndexes; the sequential case, earlier deletes hitting incoming documents, was fixed as a bug in 2011 (LUCENE-2996).
The full argument226 words
- The ordering contract.
IndexWriterclass javadoc (IndexWriter.java:117-120): "Each method that changes the index returns a long sequence number, which expresses the effective order in which each change was applied. commit also returns a sequence number, describing which changes are in the commit point and which are not." BothaddIndexesoverloads: "@return The sequence number for this operation", linking there (:3097,:3269).CHANGES.txt:8925-8927(LUCENE-7302): the numbers indicate "the effective equivalent single-threaded execution order". - The commit contract.
commit()(:4203-4210): "all indexing operations prior to this sequence will be included in the commit point, and all other operations will not"; "All sequence numbers <= this value will be reflected in the commit, and all others will not" (alsoprepareCommit(),:3730-3732). - Concurrency is allowed. "IndexWriter instances are completely thread safe, meaning multiple threads can call any of its methods, concurrently" (
:167-168). No javadoc exemptsaddIndexes. - The sequential form was fixed as a bug. LUCENE-2996 (#4070, 2011): "addIndexes applies the previous deletes on the incoming indexes as well, which is incorrect"; fixed by the up-front flush (
CHANGES.txt:17452-17454). The concurrent window remained, and since LUCENE-7302 (6.2.0) the returned numbers define which delete is "previous". - No upstream test covers it.
TestIndexingSequenceNumbersnever callsaddIndexes; the concurrentTestAddIndexescases (CommitAndAddIndexes*,TestAddIndexes.java:1065ff.) check exceptions and document counts.
The devil's-advocate check370 words
Strongest case for "intended": in LUCENE-1335 (#2412, 2008) @mikemccand 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", and @yonik agreed: "higher level synchronization by the user is the right way to ensure/enforce an ordering." On dev@ in February 2022 the LUCENE-10216 author (@vigyasharma) noted that "we may still get such a delete before the API completes, and those deletes would get applied, this is likely a best effort scenario, right?", and @msokolov replied "I think your analysis is correct, but I wasn't around for that so I am just reading the historical record". The NRT javadoc also lets a reader see only the foreign segments "successfully copied over, so far" while an addIndexes runs (IndexWriter.java:504-505), which fits a best-effort reading. The index is never corrupt, and addIndexes is usually a bulk step without concurrent writes.
Why it fails: the sequence-number javadoc was written in 2016, eight years after LUCENE-1335, and promises the effective order for every index-changing method, addIndexes included, with no exception; commit() promises that larger numbers are not in the commit. The 2008 caveat is not in today's docs, and it was about concurrent admin calls, not ordinary deletes and updates. If callers must order addIndexes themselves, the number it returns says nothing about its order relative to overlapping calls. Partial NRT visibility is about what a reader sees mid-call; it does not let a commit contain an operation with a larger seqNo. The 2022 exchange was a list question during a refactor, without the sequence-number framing, and was never documented or filed; it does show maintainers may accept a documentation fix. An independent reviewer from another model family (OpenAI GPT-5.6) weighed the LUCENE-1335 counterpoint it was given (it did not know the 2022 thread) and rated it moderate; a second cross-model fact check rated the intended case moderate for the delete/update part and weak-to-moderate for the commit part. Verdict: a contract violation as the docs stand; code fix or javadoc fix is the maintainers' call.
Recommended B: publish at a full-flush boundary instead of flushing up front (suggested)
Publish the added segments at a full-flush boundary under fullFlushLock: freeze the buffered deletes, give the segments the next delete generation, return the boundary as the sequence number. It replaces the up-front flush (still one full flush per call), but addIndexes now waits for a concurrent commit's flush. The repro and :lucene:core:test (8,869 tests, one seed) pass. AI tool-designed, not maintainer-reviewed.
All options and trade-offs5 options ยท 1,104 words
The root cause is that addIndexes takes a place in the sequence-number order without taking one in the delete-generation order or in the commit cut. A code fix publishes the added segments at a full-flush boundary, the way prepareCommit cuts a commit point: freeze the buffered deletes first (those in the current delete queue, all with smaller seqNos), give the added segments the next generation, return the boundary as the seqNo, and hold fullFlushLock so commit and NRT reopen see the segments on the right side of their cut. Options B and A were built and validated in a scratch worktree at ea57360912ed (removed afterwards), C was tested with the harness, D and E are untested ideas. A javadoc fix (E) is a legitimate alternative given LUCENE-1335 and the 2022 thread.
B: publish at a full-flush boundary instead of flushing up front (suggested) (validated by the checks) (recommended)
Both overloads drop their up-front flush(false, true) and publish through one new method, publishAddedSegments, which runs the full flush at the end of the call, under fullFlushLock. The added segments get a generation through a new SegmentCommitInfo#setBufferedDeletesGenKeepId, because the existing setter also assigns a new commit id and addIndexes(Directory...) must keep the source segment's id (TestIndexWriter#testSegmentCommitInfoId). Dropping the up-front flush is safe because its documented job (LUCENE-2996, deletes buffered before the call) is now done by the publication flush, which freezes those deletes before the added segments get their later generation. +84/-49 lines in IndexWriter.java and SegmentCommitInfo.java. AI tool-designed; no maintainer has reviewed it.
private long publishAddedSegments(List<SegmentCommitInfo> infos, long totalDocs) throws IOException {
doBeforeFlush();
synchronized (fullFlushLock) {
long seqNo = docWriter.flushAllThreads(); // freezes the current delete queue (all smaller seqNos)
if (seqNo < 0) seqNo = -seqNo;
publishFlushedSegments(true); // ... and gives them their delete generation
synchronized (this) {
ensureOpen();
reserveDocs(totalDocs);
long gen = bufferedUpdatesStream.getNextGen();
bufferedUpdatesStream.finishedSegment(gen);
for (SegmentCommitInfo info : infos) info.setBufferedDeletesGenKeepId(gen);
segmentInfos.addAll(infos);
checkpoint();
}
return seqNo; // the boundary, as prepareCommit returns
} // finally: finishFullFlush, processEvents(false), doAfterFlush; on failure delete the copied files
}Trade-offs
- Same number of full flushes as today: one per call, moved from the start to the end (counted from the writer's InfoStream: stock 1, B 1, A 2). Documents buffered before the call or indexed during the copy are flushed just before the added segments are appended, so they get smaller doc IDs, in line with their smaller seqNos.
- The flush no longer applies all deletes (the old
flush(false, true)did); they are applied lazily, as after any full flush. addIndexesnow waits forfullFlushLockat the end, so it waits for a concurrent commit's or NRT reopen's flush window, and they wait for its publication: the point of the fix, but new blocking.- The
doBeforeFlush/doAfterFlushhooks now run around the publication instead of at the start, anddoAfterFlushruns whilefullFlushLockis still held (stockdoFlushcalls it after releasing the lock); a subclass hook that blocks or throws there was not tested. Two of the cross-model reviewer's test classes pauseaddIndexesindoAfterFlush; under B that pause comes after the publication, so their delete gets the larger seqNo and their precondition fails (under A they pass). They are not regression tests for B. - The reviewer's third class has a commit test that waits for
addIndexeswhile the commit is paused inside its flush. Any fix that ordersaddIndexesafter the in-progress commit turns that wait into a 30 s timeout that closes the writer: it fails that way under A (the reviewer's own run) and under B; its delete test and controls pass under B. - A small cost probe, 3 reps per option: 200 rounds of add + delete +
addIndexeson one thread give the same 400 segments for stock, A and B (stock 1197/608/567 ms, A 1634/918/559 ms, B 1397/705/457 ms: too few reps to compare times); with a concurrent indexer the flushed-segment counts varied from rep to rep (stock 186/75/96, A 137/190/188, B 180/84/90), too much to rank the options. Not a benchmark.
What the checks validated
- The repro passes 4/4 with assertions on and off; the study verifier's 9-test class passes 9/9 (its seqNos shift from stock, e.g. delete 4 <
addIndexes5 and commit 6 < 7, and the states now match them); the default-settings program shows 0 violations;-Ptests.iters=5of the repro,TestAddIndexesandTestIndexingSequenceNumbers: 255 tests, 0 failures. - All of
:lucene:core:test: 748 classes, 8,869 tests, 0 failures, 322 skipped (one seed). - With
-Ptests.asserts=false: the repro and 12 IndexWriter / addIndexes / doc-values-update suites, 412 tests, 0 failures. :lucene:miscTestBPReorderingMergePolicy,:lucene:benchmarkTestAddIndexesTaskand all of:lucene:backward-codecs(2,339 tests): 0 failures.:lucene:core:check -x testpasses (google-java-format, ecjLint, forbiddenApis) aftertidyre-wrapped two comment lines of the new test.- Not done: neither fix was run through the study's randomized stress harness or Fray.
The same publishAddedSegments idea, but the up-front flush(false, true) stays (the study verifier's verification-only patch, comments marked "FV-VERIFY"). +63/-39 lines.
Trade-offs
Two full flushes per call (measured). #14994 / PR #15025 (2025) removed per-merge full flushes from addIndexes(CodecReader...) because they cost too much, so expect the same objection. The smallest conceptual change.
What the checks validated
- The repro 4/4; all of
:lucene:core:test: 748 classes, 8,869 tests, 0 failures, 322 skipped. - Earlier: the study verifier's 9 tests on 5 seeds and 384 tests in 10 existing classes; the cross-model reviewer's
TestAddIndexes39/39 andTestIndexingSequenceNumbers4/4.
Wrap both publication blocks in synchronized (fullFlushLock). No extra flush; +19/-13 lines.
Trade-offs
Fixes the commit part only: with the harness the commit test passes and both delete tests still fail ([] instead of [100]).
Freeze the global delete buffer instead of running a full flush, then give the added segments the next generation.
Trade-offs
Cheaper (no segment flush), but the freeze and the seqNo must be atomic with respect to concurrent deletes (DocumentsWriterDeleteQueue.add synchronizes on the queue, DocumentsWriterDeleteQueue.java:174), it must cooperate with a full flush already in progress, and it still needs fullFlushLock for commit.
Say in both addIndexes javadocs, and in the class-level sequence-number paragraph, that deletes and updates running concurrently with addIndexes may also apply to the added documents and that a concurrent commit may contain them, whatever their sequence numbers.
Trade-offs
Matches LUCENE-1335 and the 2022 "best effort" reading; leaves the returned seqNo without ordering meaning for calls that overlap addIndexes. The cheapest change.
Multi-threaded stress testing with an exact oracle built from the API contracts: replaying operations in sequence-number order must give the final state, and each commit must equal the operations numbered <= its own. With addIndexes added, it flagged violations in 9 of 100 seeds (10 s); the controlled-concurrency tester Fray re-found it. None of this is formal verification.
The method in full229 words
Method: multi-threaded stress testing with an exact oracle (the study's non-formal baseline), re-found by controlled-concurrency testing.
- A 2026-10-22 study of Lucene by AI tools covered the IndexWriter/NRT layer because an earlier bug there (now #16900) was the study's starting point. So the area was hinted; this bug was not (the study tags it a blind find).
- An AI tool wrote an NRT harness: 2-3 writer threads and 1-2 reader threads over randomized writer configurations, checked by an oracle built only from API contracts: linearizability of what readers saw, and a sequence-number check (replaying the operations in seqNo order must give the final state; each commit point must equal the operations with seqNo <= the commit's). With
addIndexesadded to the operation mix, plain stress flagged seqNo-order and commit-point violations in 9 of 100 seeds at 20 runs each (10 s), 16 of 100 at 200 runs each. - The controlled-concurrency tester Fray re-found it (PCT 14, POS 13, Random 5 of 100 seeds at 20 iterations each). None of this is formal verification.
- An independent AI verifier wrote its own deterministic test (9 tests), swept six releases and confirmed the root cause with a patch. This hunt minimised the test, re-verified it on
ea57360912ed, had it reviewed adversarially by OpenAI GPT-5.6, pinned the history across 89 releases and the introducing commits, searched for prior reports and validated three fixes.
No issue, PR, commit, JIRA entry or downstream report describes it (searched 2026-10-25), but it is partially known: in a 2022 dev@ thread the delete window was called "best effort", and LUCENE-1335 (2008) declined API promises for concurrent commit and addIndexes.
Searches5 searches
No issue, PR, commit, JIRA entry or downstream report describes this bug, and it is not fixed upstream. But it is partially known: on dev@ in February 2022 the LUCENE-10216 author noticed the delete window and asked whether it was "a best effort scenario"; a committer replied that the analysis looked correct, adding that they were only reading the historical record (no sequence numbers, no updates, no commit; nothing filed), and LUCENE-1335 (2008) declined API promises for concurrent commit/addIndexes.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), with migrated JIRA issues | 2026-10-25 | 45 queries from the scripted prior-report search plus 39 manual phrasings (for example addIndexes sequence number / seqNo, addIndexes concurrent delete, bufferedDeletesGen, fullFlushLock, "best effort", "transaction log", is:open seqNo) | no report; closest: #2412 (LUCENE-1335, 2008 stance), #4070 (LUCENE-2996, sequential case fixed 2011), #8356 (LUCENE-7302, design intent), #14994 / PR #15025 (cost of full flushes), #11253 / PR #633 (LUCENE-10216 rework, no seqNo discussion) |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-25 | 36 queries; five threads read in full | dev@ 2022-02 "AddIndexes(CodecReader...) API Question" (the delete window called best effort; partial prior mention); dev@ 2023-04 "Should IndexWriter.flush return seqNo?" (design intent) |
| Open PRs | 2026-10-25 | all 412 open PRs with their files; the 22 touching IndexWriter, SegmentCommitInfo, FrozenBufferedUpdates or DocumentsWriter* grepped for addIndexes, copySegmentAsIs, BufferedDeletesGen, getNextSequenceNumber, fullFlushLock, seqNo | none changes addIndexes publication, the added segments' generation or the returned seqNo |
| Commits, JIRA archive, downstream code, web | 2026-10-25 | 6 commit, 8 JIRA, 4 downstream and 6 web queries | no fix; downstream designs that call addIndexes into a live writer (OpenSearch#18576 / #19530), exposure not checked |
| Earlier searches | 2026-10-21 to 2026-10-25 | the study's ~30 phrasings and an earlier scripted prior-report search run (51 queries) | no report; neither surfaced the 2022 thread |
Introduced in 6.2.0 by LUCENE-7302 (#8356, commit 7ee1f422458, 2016), which made both addIndexes methods return a sequence number while their publication stayed outside the full flush. All 89 releases 6.2.0-10.5.2 and main are affected; the overlap itself is older, but only 6.2.0 added the promise it breaks.
Versions tested12 rows
Introduced by the LUCENE-7302 series (#8356, "IndexWriter should tell you the order of indexing operations", @mikemccand): commit 7ee1f422458 "cutover all IW APIs that change the index to return seq no" (branch_6x, authored 2016-05-24) made both addIndexes methods return a sequence number while their publication path stayed outside the full flush; 5361d67996a in the same series added the javadoc contract. The same change reached master as e4a21330a20 through the merge b1fb142af00 (2016-06-05). First affected release: 6.2.0; every release since is affected, and main is not fixed. The behaviour itself is older (a probe without sequence numbers shows the same overlap on 6.0.0, 6.1.0 and 6.2.0); what 6.2.0 added is the promise it breaks.
| Version or commit | Verdict | Result |
|---|---|---|
| 6.0.0, 6.0.1, 6.1.0 | no seqNos | the probe does not compile: addIndexes, deleteDocuments and commit return void |
| 6.2.0 | first affected | 5 of 5 checks violated, 3 controls pass; also with -da and in 10 of 10 JVM launches |
| 6.2.0-6.6.6 (15 releases) | affected | 5 of 5 violated |
| 7.0.0-7.7.3 (14 releases) | affected | 5 of 5 violated |
| 8.0.0-8.11.4 (27 releases) | affected | 5 of 5 violated |
| 9.0.0-9.12.3 (21 releases) | affected | 5 of 5 violated |
| 10.0.0-10.5.2 (12 releases) | affected | 5 of 5 violated; the JUnit repro fails 3 of 4 on 10.5.2 |
main ea57360912ed | affected | JUnit repro 3 of 4 fail with -ea (20 of 20 seeds) and -da; probe 5 of 5 |
branch_6x 88dd1aad774 (parent) | commit-level | cannot express the scenario: addIndexes still returns void |
branch_6x 7ee1f422458 | commit-level | fails 4 of 4 applicable checks |
master dee57ce23fb / b1fb142af00 (merge) | commit-level | first parent has no seqNos; the merge fails 5 of 5 |
fix B / fix A on ea57360912ed | fixed | repro 4/4 pass; :lucene:core:test 8,869 tests (322 skipped), 0 failures |
An independent verifier wrote its own 9-test repro and patch; the minimal test fails 3 of 4 on main with assertions on (20 of 20 seeds) and off, and on 10.5.2; a cross-model reviewer (OpenAI GPT-5.6) reproduced it with its own hooks. Fact and diagram checks found four errors in the text and the diagram, all fixed.
Checks and verdicts358 words
Every code citation was re-read at ea57360912ed and every repro re-run on it; the stress and Fray hit rates are the study's (on 7cecf79523). The study's checks came first; this page was then fact-checked independently and by a reviewer from another model family.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier | new bug | concurrency-testing verifier wrote its own 9-test repro (6 violations, 3 controls), ran 11 seeds and six releases (6.6.6-10.5.2), and confirmed the root cause with a verification-only patch. |
| Public-list rerun | reproduces | The study repro on main b83101cdd2: Tests run 9, Failures 6. |
| Hunt re-verification | real | The minimal 4-test JUnit fails 3 of 4 on main with -ea and -da and on 10.5.2; 20 of 20 derived seeds; the study patch fixes it. |
| Cross-model refute review | real, high confidence | OpenAI GPT-5.6 wrote its own tests with different hooks (the protected doAfterFlush, a pausing directory), reproduced the delete case with -ea and -da, and rated the intended-behaviour argument moderate. The hunt re-ran its three test classes on main: both overloads and the commit case fail, the controls pass. |
| History | since 6.2.0 | All 89 releases 6.2.0-10.5.2 affected; pinned to 7ee1f422458 (branch_6x) and the master merge b1fb142af00 by commit-level runs. |
| Prior art | partially known | No report or fix; the delete window was called "best effort" on dev@ in 2022, and LUCENE-1335 (2008) declined promises for concurrent commit/addIndexes. |
| Fix validation | 2 fixes validated, 1 partial | Options B and A pass the repro and all of :lucene:core:test; B also passes :lucene:core:check, -da suites and :lucene:backward-codecs; C fixes only the commit case. |
| Security screen | not security | Out of scope, public. |
| Independent fact and diagram checks | errors fixed | A fact check re-ran the repro (gradle and harness, -ea and -da, 10.5.2), fix B and the 13 named suites; a cross-model fact check (OpenAI GPT-5.6) re-read every citation. Their errors (an untraceable cost-probe range, the fix-B seqNo numbers, the default-settings claim) and the diagram check's error (the copy drawn in the segment list before publication) were fixed; the default-settings claim is now an executed run. |
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 notes503 words
- Registry title
- addIndexes publishes segments outside the full-flush boundary, breaking sequence-number ordering
- Area
- index / addIndexes
- Priority
- Low, rank 262: Default config, but only applications that order or replay by Lucene's own sequence numbers while addIndexes runs are hurt, the major downstream engines keep their own, and a documentation fix is a likely answer.
- Impact class
- silent wrong results
- Impact note
- Default configuration; applications that run addIndexes concurrently with deletes or updates and rely on the sequence-number contract; released (6.6.6+).
- Summary
- You call addIndexes while other threads delete or update documents, and you rely on sequence numbers to order those operations. You expect operations with a smaller seqNo not to touch the added documents, and a commit to contain only operations up to its seqNo. Instead deletes or updates with a smaller seqNo hit the added documents, and a commit can include later operations.
- Introduced
- LUCENE-7302 (#8356; 7ee1f422458 on branch_6x, reaching master through the merge b1fb142af00) [exec: commit-level runs] (2016-05-24)
- First affected version
- 6.2.0 [exec]
- Affected versions
- 6.2.0 to 10.5.2 (all 89 releases) and main [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- non-formal baseline: stress
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
ea57360912ed(commit of 2026-10-24; runs 2026-10-25 and 2026-10-26), assertions on (20 derived seeds) and off; a default-settingsmain()program (new IndexWriterConfig(), default codec,FSDirectory); 10.5.2; a portable probe on all 89 Maven Central releases 6.2.0-10.5.2 plus 6.0.0, 6.0.1 and 6.1.0 (where it does not compile: no sequence numbers); the LUCENE-7302 commits and their parents (commit-level runs); fix options A, B and C atea57360912ed(B and A through all of:lucene:core:testin a scratch worktree, B also through:lucene:core:check -x test, all of:lucene:backward-codecsand theaddIndexestests of:lucene:miscand:lucene:benchmark).
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-120, 167-168, 1864-1867, 2989, 3097, 3119, 3121-3192, 3183, 3194-3211, 3269, 3350-3369, 3648-3705, 3806-3850, 4203-4210, 4400, 4554, 6784Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SegmentCommitInfo.java:88; BufferedUpdatesStream.java:75, 280; FrozenBufferedUpdates.java:191, 375, 460; DocumentsWriterDeleteQueue.java:129-133
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:117-120, 167-168, 504-505, 3097, 3269, 3730-3732, 4203-4210Lucene main ea57360912ed: lucene/CHANGES.txt:8925-8927 (LUCENE-7302), 17452-17454 (LUCENE-2996)Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1065, 1129, 1175, 1286, 1314; TestIndexingSequenceNumbers.java (0 addIndexes calls)
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:174; SegmentCommitInfo.java:310-317, 457-460
- TestAddIndexesConcurrentDeleteSeqNo.java (JUnit test):
repro/LFV-014/src/org/apache/lucene/index/TestAddIndexesConcurrentDeleteSeqNo.java(80 lines)
The PNG is a one-screen summary card. Every number comes from a read-only trace of the repro on ea57360912ed.
assets/LFV-014/bug.png(478 KB)
![Three panels. 1, You do: index w holds doc id=k, val=1 (committed), index src holds id=k, val=100; thread A calls w.addIndexes(src), thread B calls w.deleteDocuments(id:k) during A's call, before A publishes, and gets d = 5 while A gets a = 6; the class javadoc sentence (IndexWriter.java:117-118). 2, Why (IndexWriter.java at ea57360912ed): flush(false, true) runs only at the start (:3119), then the segment files are copied with no IndexWriter lock held; the copy keeps bufferedDeletesGen = -1 (copySegmentAsIs, :3648-3705; the default, SegmentCommitInfo.java:88); it is published under the writer's monitor only (:3194-3211), with seqNo = docWriter.getNextSequenceNumber() and no full flush; at the next flush the delete packet with delGen 2 applies to every segment with getBufferedDeletesGen() <= 2, so to the copy too. 3, Expected from the seqNos versus actual live doc values in an NRT reader (last row: the commit point): deleteDocuments [100] vs []; updateDocument [2, 100] vs [2]; updateNumericDocValue [7, 100] vs [7, 7]; the delete with addIndexes(CodecReader...) [100] vs []; commit 7 < 8: commit point [1, 2] vs [1, 2, 100]. Footer: executed on OpenJDK 25.0.3, the same violations in all releases 6.2.0-10.5.2 and main (the seqNo values vary).](../assets/LFV-014/bug.png)