All findings · Lucene finding · LFV-014 · addindexes-seqno-contract

addIndexes returns a sequence number that contradicts concurrent deletes, updates and commits

A delete with sequence number 5 that runs during an addIndexes numbered 6 still removes the added document: live values [], not [100]. Updates lose or overwrite it, a commit numbered 7 can contain an addIndexes numbered 8, and no error is raised; every release since 6.2.0 has it.

Summary

Priority
Low, rank 262Default 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
silent wrong results
Since
6.2.0 (LUCENE-7302, 2016)introduced 2016-05-24 · over 10 years
Affected
all 89 releases 6.2.0 to 10.5.2, and main
Status
documented
Found by
multi-threaded stress testing with a sequence-number oracle; Fray re-found it

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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).
The two thread lanes and their sequence numbers, the three steps inside 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.
  1. Writer w holds one committed document id:k, val=1; a second index src holds id:k, val=100. Thread A calls w.addIndexes(src); before it publishes, thread B calls w.deleteDocuments(id:k).
  2. The delete returns sequence number 5 and addIndexes returns 6. By the IndexWriter javadoc these numbers give "the effective order in which each change was applied": the delete came first.
  3. 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.
  4. Instead it shows []: the added segment keeps delete generation -1, so the buffered delete applies to it too. updateDocument gives [2], not [2, 100]; a doc-values update [7, 7], not [7, 100]; a commit() that returns 7 holds val=100 from the addIndexes that returns 8.
  5. 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.

Who is affected

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.

What you do

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.

A tiny example

The writer w holds one committed document id:k, val=1; a second index src holds one document id:k, val=100.

stepthreadcallreturnswhat you expect
1Aw.addIndexes(src) starts (the repro parks it after its up-front flush)
2Bw.deleteDocuments(id:k) before A publishesseqNo 5val=1 is deleted
3AaddIndexes finishesseqNo 6val=100 arrives after the delete
4Bopen an NRT readerlive 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.

Why: two orders that disagree

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:

  1. It flushes only at the start. addIndexes begins with flush(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 the IndexWriter monitor (:3121-3192; copySegmentAsIs, :3648-3705). The copies go into a local list, not yet into the writer's segment list segmentInfos (:3183).
  2. The delete during the copy waits in the buffer. deleteDocuments appends it to the delete queue and returns seqNo 5 (:1864-1867 -> DocumentsWriterDeleteQueue.java:129-133). Nothing is applied yet.
  3. addIndexes publishes with a later seqNo but no generation. Under the IndexWriter monitor only, it takes docWriter.getNextSequenceNumber() = 6 and appends the segments (:3194-3211; CodecReader :3350-3369). The new segments keep bufferedDeletesGen = -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.
  4. 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 with bufferedDeletesGen <= delGen (IndexWriter.java:6784; the skip test for term deletes is FrozenBufferedUpdates.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.
  5. Commit cuts at a full flush that addIndexes ignores. prepareCommit holds fullFlushLock from the full flush that fixes its seqNo (:3806-3809) until it clones the segment list (:3850). addIndexes publishes 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 addIndexes on 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 say addIndexes (6) came after updateDocument (5) while the index kept the update; and a replay of everything after commit 7 re-runs addIndexes (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, with new IndexWriterConfig() (only an InfoStream added for the hooks; default codec, TieredMergePolicy, ConcurrentMergeScheduler, commitOnClose=true) and FSDirectory, shows all three violations with assertions on and off, and passes under fix B. Both public overloads, public API only. The JUnit repro sets NoMergePolicy and SerialMergeScheduler only to make its thread hooks deterministic; the study's stress harness hit it under randomized configurations.
  • It needs concurrency. An addIndexes overlapping 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 addIndexes is 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.

Minimal repro

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.

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

import java.util.concurrent.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());
        }
      }
    }
  }
}
Command
# 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.

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

More files, environment, full output42 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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)

Why it looks unintended

  • 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".
  • IndexWriter is "completely thread safe" with no exception for addIndexes; the sequential case, earlier deletes hitting incoming documents, was fixed as a bug in 2011 (LUCENE-2996).
The full argument226 words
  • The ordering contract. IndexWriter class 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." Both addIndexes overloads: "@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" (also prepareCommit(), :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 exempts addIndexes.
  • 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. TestIndexingSequenceNumbers never calls addIndexes; the concurrent TestAddIndexes cases (CommitAndAddIndexes*, TestAddIndexes.java:1065 ff.) 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.

Suggested fix

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.
  • addIndexes now waits for fullFlushLock at 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 / doAfterFlush hooks now run around the publication instead of at the start, and doAfterFlush runs while fullFlushLock is still held (stock doFlush calls 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 pause addIndexes in doAfterFlush; 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 addIndexes while the commit is paused inside its flush. Any fix that orders addIndexes after 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 + addIndexes on 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 < addIndexes 5 and commit 6 < 7, and the states now match them); the default-settings program shows 0 violations; -Ptests.iters=5 of the repro, TestAddIndexes and TestIndexingSequenceNumbers: 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:misc TestBPReorderingMergePolicy, :lucene:benchmark TestAddIndexesTask and all of :lucene:backward-codecs (2,339 tests): 0 failures.
  • :lucene:core:check -x test passes (google-java-format, ecjLint, forbiddenApis) after tidy re-wrapped two comment lines of the new test.
  • Not done: neither fix was run through the study's randomized stress harness or Fray.

A: the study's patch, a second full flush at publication (validated by the checks)

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 TestAddIndexes 39/39 and TestIndexingSequenceNumbers 4/4.

C: only serialize the publication with commit (partial)

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

D: freeze only the buffered deletes at publication (untested)

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.

E: document the behaviour (untested)

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.

How it was found

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 addIndexes added 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), with migrated JIRA issues2026-10-2545 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-2536 queries; five threads read in fulldev@ 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 PRs2026-10-25all 412 open PRs with their files; the 22 touching IndexWriter, SegmentCommitInfo, FrozenBufferedUpdates or DocumentsWriter* grepped for addIndexes, copySegmentAsIs, BufferedDeletesGen, getNextSequenceNumber, fullFlushLock, seqNonone changes addIndexes publication, the added segments' generation or the returned seqNo
Commits, JIRA archive, downstream code, web2026-10-256 commit, 8 JIRA, 4 downstream and 6 web queriesno fix; downstream designs that call addIndexes into a live writer (OpenSearch#18576 / #19530), exposure not checked
Earlier searches2026-10-21 to 2026-10-25the study's ~30 phrasings and an earlier scripted prior-report search run (51 queries)no report; neither surfaced the 2022 thread

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
6.0.0, 6.0.1, 6.1.0 no seqNosthe probe does not compile: addIndexes, deleteDocuments and commit return void
6.2.0 first affected5 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) affected5 of 5 violated
7.0.0-7.7.3 (14 releases) affected5 of 5 violated
8.0.0-8.11.4 (27 releases) affected5 of 5 violated
9.0.0-9.12.3 (21 releases) affected5 of 5 violated
10.0.0-10.5.2 (12 releases) affected5 of 5 violated; the JUnit repro fails 3 of 4 on 10.5.2
main ea57360912ed affectedJUnit repro 3 of 4 fail with -ea (20 of 20 seeds) and -da; probe 5 of 5
branch_6x 88dd1aad774 (parent) commit-levelcannot express the scenario: addIndexes still returns void
branch_6x 7ee1f422458 commit-levelfails 4 of 4 applicable checks
master dee57ce23fb / b1fb142af00 (merge) commit-levelfirst parent has no seqNos; the merge fails 5 of 5
fix B / fix A on ea57360912ed fixedrepro 4/4 pass; :lucene:core:test 8,869 tests (322 skipped), 0 failures

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier new bugconcurrency-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 reproducesThe study repro on main b83101cdd2: Tests run 9, Failures 6.
Hunt re-verification realThe 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 confidenceOpenAI 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.0All 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 knownNo 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 partialOptions 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 securityOut of scope, public.
Independent fact and diagram checks errors fixedA 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes503 words

Finding record

LFV-014, snapshot of 2026-11-06

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-settings main() 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 at ea57360912ed (B and A through all of :lucene:core:test in a scratch worktree, B also through :lucene:core:check -x test, all of :lucene:backward-codecs and the addIndexes tests of :lucene:misc and :lucene:benchmark).

Lucene and upstream sources

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, 6784
  • Lucene 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-4210
  • Lucene 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

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

Repro files

About the diagrams

The PNG is a one-screen summary card. Every number comes from a read-only trace of the repro on ea57360912ed.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.