One flag, two meanings. deleteAll() turns merges off while it waits for running merges to abort; addIndexes(CodecReader...) reads "merges off" as "writer closed", one line after checking that the writer is open.
- Merges.
IndexWritermerges small segments into bigger ones on background threads (ConcurrentMergeSchedulerby default). deleteAll()drops every document and segment. First it turns merges off, aborts the running ones and waits for them to stop; then it turns merges back on.addIndexes(CodecReader...)copies other readers' documents into the writer by running a merge, so it checks that merges are on before it starts.
deleteAll() turns merges off and waits for the running merge to abort; addIndexes passes ensureOpen(), finds merges off and reports the open writer as closed. Select the still to open it at full size. Generated with AI, checked against the code.- One thread calls
deleteAll()while a background merge is running (in the repro: 2 one-document segments being merged).deleteAll()turns merges off, aborts that merge and waits for it to stop. - During that wait a second thread calls
addIndexes(reader)on the same writer with a 1-document reader. It checks that the writer is open (it is), then that merges are on (they are not). - You expect the call to succeed or to fail with
MergeAbortedException, whichdeleteAll()'s javadoc names for concurrentaddIndexes(CodecReader[])callers and which a caller gets when its own merge is the one aborted. - Instead it throws
AlreadyClosedException: this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API. The writer is open:isOpen()is true,deleteAll()returns normally and the nextaddIndexescall succeeds. - It happens without test hooks too: a stress run that overlaps the two calls got 9 of these and 107
MergeAbortedExceptions in 277deleteAll()calls. Nothing is lost; only the exception and its message are wrong.
Applications that call deleteAll() and addIndexes(CodecReader...) on one writer from different threads, with default settings, while a merge runs. A call that hits the check is rejected either way and nothing is lost; the type and message are wrong. Code that treats AlreadyClosedException as "writer gone" could discard a healthy writer (not observed). Releases 9.4.0 to 10.6.0.
Mechanism, terms and numbers570 words
deleteAll() turns merges off, aborts the running merges and waits for them to stop, releasing the writer's lock while it waits. addIndexes(CodecReader...) checks, after its flush, that the writer is open and then that merges are on, and reports "merges off" as AlreadyClosedException("this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API"). A call that reaches this check during deleteAll()'s wait therefore says the writer is closed although it is open: isOpen() is true, deleteAll() returns normally and the next call succeeds. deleteAll()'s javadoc names MergeAbortedException for exactly this overlap, and a call whose own merge is already running when the abort comes gets it. A call that hits the check is rejected either way and nothing is lost or damaged, so the defect is the exception type and its false message. It became user-visible in 9.4.0 (LUCENE-10216, #633), which turned assert merges.areEnabled() into this exception; every release 9.4.0 to 10.6.0 and main are affected.
- The check.
addIndexes(CodecReader...)(:3275) flushes (:3286), validates the readers and sums theirnumDocs()(:3289-3295), callstestReserveDocs(:3296), then in one synchronized block callsensureOpen()and throwsAlreadyClosedException("this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API")ifmerges.areEnabled()is false (:3298-3304). - Who turns merges off.
merges.disable()has one caller,abortMerges()(:2823), andabortMerges()has two:rollbackInternalNoCommit()(:2621), reached only fromclose(),rollback()and the tragic-event close, each aftershouldClose()has setclosing(:1379), soensureOpen()throws first there; anddeleteAll()(:2771), which turns merges back on in afinallyblock (:2781). - The window.
abortMerges()marks running merges aborted (:2839-2844) and loops ondoWait()while any is still running (:2849-2860).doWait()iswait(1000)(:5860-5872), which releases the writer's monitor, so another thread can enter the check while merges are off. - Every hit is on an open writer. Once
ensureOpen()has passed, the writer is neither closed nor closing (isOpen()isclosing == false && closed == false,:6117-6120), and the only open-writer state with merges off isdeleteAll()'s abort wait. The "closed" message is false every time the branch fires. - Only this check reports "merges off" as
AlreadyClosedException. For regular merges,IndexWriter's privateregisterMergethrowsMergeAbortedExceptionwhen merges are off (:5165-5167), andupdatePendingMergesjust finds no merges (:2463-2465).addIndexes(CodecReader...)registers its own merges throughAddIndexesMergeSource.registerMerge(:3311,:3408-3417), which has no such check, and throwsMergeAbortedExceptionwhen its own merge was aborted (:3377-3378). So timing decides: a caller whose own merge is already queued or running whendeleteAll()aborts getsMergeAbortedException(executed for a running merge); one that reaches the check during the wait gets the falseAlreadyClosedException; and, by code reading only, a caller past the check that registers its merge after the abort is not aborted and can succeed. - Nothing of the rejected call is kept beyond its normal up-front work (the flush at
:3286, which can write buffered documents as a segment, and the reader validation at:3289-3295). The exception comes beforefindMerges(:3307), before any merge is registered and beforereserveDocs(:3356).AlreadyClosedExceptionis anIllegalStateException, not anError, so thecatch (Error tragedy)(:3390-3392) does not run and the writer is not closed.
Exposure and likelihood308 words
- Who. Applications that overlap
deleteAll()andaddIndexes(CodecReader...)on oneIndexWriterwhile some merge is running (with none running,deleteAll()never waits and the check cannot see merges off). DefaultIndexWriterConfig,ConcurrentMergeSchedulerandTieredMergePolicy; no opt-in setting, no scale.IndexWriteris documented as "completely thread safe, meaning multiple threads can call any of its methods, concurrently" (IndexWriter.java:167-168). - Effect. A call that hits the check is rejected either way (today and with the recommended fix); with the bug the exception type is wrong and its message false. For that call: no data loss, no index damage, no hang. Afterwards the writer keeps working: no tragic exception,
deleteAll()removed everything, nothing of the rejected call was added, no leaked document reservation,CheckIndexclean (executed probe). Other overlap positions were not all executed (step 5 of the mechanism). - Why the type matters.
AlreadyClosedExceptionis howIndexWriterreports a writer that is gone, and it is unchecked;MergeAbortedExceptionis anIOException. Code that reacts toAlreadyClosedExceptionby discarding, rolling back or reopening the writer would throw away a healthy writer for a call it could simply retry. Plausible, but not executed anywhere. - How often without hooks. A stress program built to overlap the calls (3
addIndexes(CodecReader x20)threads, 1 indexing thread, adeleteAll()loop) saw 4 false "closed" exceptions against 95MergeAbortedExceptions in 252deleteAll()calls (LogDocMergePolicy), 5 against 110 in 227 and 9 against 107 in 277 (defaultTieredMergePolicy), and 0 against 17 in a lighter run: numbers from a loaded host, not a general frequency. - How likely. Rare: it needs this specific overlap, and no user report was found.
- Workaround. Treat
AlreadyClosedExceptionfromaddIndexes(CodecReader...)as fatal only whenwriter.isOpen()is false, and retry otherwise (a heuristic: the writer can close right after the check); or do not overlapdeleteAll()withaddIndexes.
One JUnit test, public API only. Concurrency is the bug: the test thread calls deleteAll(), a second thread addIndexes, and latches order them around one held merge; no sleeps. Input: 2 one-document segments and a 1-document reader. 10 of 10 seeds fail the same way.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.AlreadyClosedException;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.ThreadInterruptedException;
public class TestAddIndexesDuringDeleteAll extends LuceneTestCase {
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new ThreadInterruptedException(e);
}
}
public void testAddIndexesWhileDeleteAllAbortsMerge() throws Exception {
CountDownLatch mergeRunning = new CountDownLatch(1);
CountDownLatch mergeAborted = new CountDownLatch(1);
CountDownLatch callerFlushed = new CountDownLatch(1);
CountDownLatch callerDone = new CountDownLatch(1);
ConcurrentMergeScheduler cms =
new ConcurrentMergeScheduler() {
@Override
protected void doMerge(MergeSource source, MergePolicy.OneMerge merge)
throws IOException {
mergeRunning.countDown();
try {
merge
.getMergeProgress()
.pauseNanos(
Long.MAX_VALUE, MergePolicy.OneMergeProgress.PauseReason.OTHER, () -> true);
} catch (InterruptedException e) {
throw new ThreadInterruptedException(e);
}
mergeAborted.countDown();
await(callerDone);
super.doMerge(source, merge);
}
};
Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setMergeScheduler(cms));
w.addDocument(new Document());
w.flush();
w.addDocument(new Document());
w.flush();
w.forceMerge(1, false);
await(mergeRunning);
Directory src = newDirectory();
try (IndexWriter sw = new IndexWriter(src, new IndexWriterConfig())) {
sw.addDocument(new Document());
}
DirectoryReader srcReader = DirectoryReader.open(src);
CodecReader parked =
new FilterCodecReader((CodecReader) getOnlyLeafReader(srcReader)) {
@Override
public int numDocs() {
callerFlushed.countDown();
await(mergeAborted);
return super.numDocs();
}
@Override
public CacheHelper getCoreCacheHelper() {
return null;
}
@Override
public CacheHelper getReaderCacheHelper() {
return null;
}
};
AtomicReference<Throwable> thrown = new AtomicReference<>();
Thread caller =
new Thread(
() -> {
try {
w.addIndexes(parked);
} catch (Throwable t) {
thrown.set(t);
} finally {
callerDone.countDown();
}
});
caller.start();
await(callerFlushed);
w.deleteAll();
caller.join();
boolean open = w.isOpen();
w.close();
srcReader.close();
dir.close();
src.close();
// expected: success or MergeAbortedException (deleteAll javadoc); actual: AlreadyClosedException on an open writer
assertFalse(
"writer isOpen()=" + open + ", but addIndexes threw " + thrown.get(),
open && thrown.get() instanceof AlreadyClosedException);
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestAddIndexesDuringDeleteAll -Ptests.seed=DEADBEEF
- Expected
addIndexessucceeds, or fails with an exception that does not call the open writer closed, such as theMergePolicy.MergeAbortedExceptionthatdeleteAll()'s javadoc names ("may receive"); the writer stays open.- Observed
AlreadyClosedException: this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) APIwhileisOpen()is true anddeleteAll()returns normally.
More files, environment, full output350 words
A ConcurrentMergeScheduler subclass holds the merge started by forceMerge(1, false) in doMerge until deleteAll() aborts it (OneMerge.getMergeProgress().pauseNanos(...) returns once the merge is aborted); a FilterCodecReader parks the addIndexes caller in numDocs(), which addIndexes reads after its flush and before its check. abortMerges() turns merges off before it aborts the running merge, so the caller is released with merges already off; the merge then waits for the caller to finish, so deleteAll() is still in its wait when the caller enters the check. After that the merge ends and deleteAll() returns normally.
Lint: checkGoogleJavaFormat wraps the 119-character comment above the assertion into two lines; with that change the test lint passes and the test still fails on stock and passes with the fix.
Older releases: the test fails on 9.12.3 and 10.0.0 too. On 9.4.0 and 9.3.0 its merge hook cannot run (pauseNanos there requires the merge thread that IndexWriter assigns later, inside super.doMerge), so it times out; that is a limit of the hook, not a Lucene result, and the finder's longer repro covers 9.4.0.
Regression test: this same test; with the fix it passes with assertions on and off. Its assertion only rules out AlreadyClosedException on an open writer, not which outcome the fix gives; a separate probe of the same scenario saw MergeAbortedException with the fix (-ea and -da).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release jars 10.6.0 and 10.5.2: fail with assertions on and off; 10.0.0 and 9.12.3: fail |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestAddIndexesDuringDeleteAll.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestAddIndexesDuringDeleteAll -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestAddIndexesDuringDeleteAll > testAddIndexesWhileDeleteAllAbortsMerge FAILED
java.lang.AssertionError: writer isOpen()=true, but addIndexes threw org.apache.lucene.store.AlreadyClosedException: this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API
at __randomizedtesting.SeedInfo.seed([DEADBEEF:7CFAE3F30861324C]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.junit.Assert.assertFalse(Assert.java:65)
at org.apache.lucene.index.TestAddIndexesDuringDeleteAll.testAddIndexesWhileDeleteAllAbortsMerge(TestAddIndexesDuringDeleteAll.java:118)
:lucene:core:test (FAILURE): 1 test, 1 failure
# with the fix, assertions on and off
:lucene:core:test (SUCCESS): 1 testdeleteAll()'s javadoc names this overlap: concurrentaddIndexes(CodecReader[])callers "may receiveMergePolicy.MergeAbortedExceptions".AlreadyClosedExceptionmeans "an attempt to access something that has already been closed"; hereensureOpen()has just passed and the writer stays usable.- The check replaced
assert merges.areEnabled(), and #633 chose the exception for a closing writer;deleteAll()was never discussed.
The full argument234 words
- The javadoc names another exception.
deleteAll(): "this method will forcefully abort all merges in progress. If other threads are runningforceMerge,addIndexes(CodecReader[])orforceMergeDeletesmethods, they may receiveMergePolicy.MergeAbortedExceptions." (IndexWriter.java:2743-2745). The sentence is as old asdeleteAll()itself (LUCENE-1705, 2009, then "will receive"; "may receive" since 2011); the check came in 2022. - The exception's own contract. "This exception is thrown when there is an attempt to access something that has already been closed." (
AlreadyClosedException.java:19-22);ensureOpen()throws it "if this IndexWriter is closed or in the process of closing" (IndexWriter.java:905-912). HereensureOpen()has just passed, in the same synchronized block. - No other code reports "merges off" as
AlreadyClosedException: for regular mergesregisterMergethrowsMergeAbortedExceptionin the same state (:5165-5167), the aborted-merge path of the same method throws it too (:3377-3378), andupdatePendingMergesjust finds no merges (:2463-2465). - The authors did not expect an open writer here. Up to 9.3.0 the line was
assert merges.areEnabled();, an invariant. In the #633 review the premise for the exception was a closing writer, anddeleteAll()is not mentioned in its 52 review comments, 39 reviews and 24 issue comments. - No test pins it. No upstream test overlaps
deleteAll()andaddIndexes(CodecReader...)on one writer from two threads; the one test method that calls both does so from a single thread, withaddIndexes(Directory...).
The devil's-advocate check238 words
The strongest case: the type was chosen on purpose. In the #633 review Michael McCandless (mikemccand) wrote of this check "I think this only happens if IW is aborting/closing? Maybe throw AlreadyClosedException instead? This message is otherwise sort of confusing to the poor user who sees it."; the author, Vigya Sharma (vigyasharma), replied "Changed to AlreadyClosedException.", and Michael McCandless later read it as "this IW is being concurrently closed or aborted (due to unexpected OOME maybe)?". Also, "may receive" is not exclusive, no merge exists yet at the check, and the call is rejected anyway, so it is diagnostics only. (The independent cross-model reviewer, OpenAI GPT-5.6, made this case and rated it strong; a second review rated it moderate.)
Why it fails: the stated premise, "aborting/closing" or "concurrently closed", describes exactly the states ensureOpen() rules out one line earlier; deleteAll()'s abort was not considered. The message says the writer "is closed", which is false every time the branch fires. The exception the javadoc names for this overlap is MergeAbortedException, which a call whose own merge is already queued or running gets; today timing decides whether an overlapping call gets that or the false AlreadyClosedException. And the review's own goal, a message that does not confuse the user, is better met by an exception that names the abort. Verdict: not intended, low severity; the defect and its fix concern only the exception type and message.
Recommended Throw MergeAbortedException once ensureOpen() has passed
After ensureOpen() has passed, throw MergePolicy.MergeAbortedException instead of AlreadyClosedException: the exception deleteAll()'s javadoc names for this overlap and that registerMerge throws for regular merges in the same state. A one-statement change; validated: the repro passes with assertions on and off, and 252 tests in the 6 closest IndexWriter suites pass.
synchronized (this) {
ensureOpen();
if (merges.areEnabled() == false) {
- throw new AlreadyClosedException(
- "this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API");
+ // ensureOpen() passed, so merges are only disabled by a concurrent deleteAll()
+ throw new MergePolicy.MergeAbortedException(
+ "merges are being aborted by deleteAll(); addIndexes(CodecReaders...) was aborted");
}
}All options and trade-offs3 options ยท 579 words
One fix was validated in a scratch worktree of 645d4fe14a20; the others are directions, not attempted.
One file, +3/-2 in IndexWriter.java (the finder's change, re-applied to 645d4fe14a20). ensureOpen() already reports a closed or closing writer, so this branch only ever sees deleteAll()'s abort; it now reports what the deleteAll() javadoc names, what registerMerge throws for regular merges in the same state, and what the aborted-merge path of the same method already throws.
synchronized (this) {
ensureOpen();
if (merges.areEnabled() == false) {
- throw new AlreadyClosedException(
- "this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API");
+ // ensureOpen() passed, so merges are only disabled by a concurrent deleteAll()
+ throw new MergePolicy.MergeAbortedException(
+ "merges are being aborted by deleteAll(); addIndexes(CodecReaders...) was aborted");
}
}Trade-offs
- Same outcome for the caller (the call is rejected, nothing is added); only the type and message change.
- The type alone does not tell a caller whether its merge was aborted or the call was turned away at this check; only the message does.
MergeAbortedExceptionis a checkedIOException;addIndexesalready declaresIOExceptionand already throwsMergeAbortedException(:3378), so no new type reaches callers. Code that caughtAlreadyClosedExceptionfor this case stops seeing it, which for an open writer is the point.- Its javadoc says it is thrown "because
IndexWriter#abortMergeswas called" and is "normally ... privately caught" (MergePolicy.java:573-577);deleteAll()callsabortMerges(), and its javadoc already exposes the exception to these callers.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, 2026-10-31 (worktree removed afterwards): stock, the minimal test fails with assertions on and off; with the fix it passes with assertions on and off. - Closest suites with the fix:
TestAddIndexes(39),TestIndexWriterDelete(27, 6 skipped),TestIndexWriter(119, 1 skipped),TestIndexWriterExceptions(40, 3 skipped),TestIndexWriterMerging(13),TestIndexWriterWithThreads(14): 252 tests, 0 failures, 10 skipped (seedDEADBEEF). - Lint with the fix and the test's comment wrapped onto two lines (google-java-format rejects the 119-character line):
checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass; that copy also fails on stock and passes with the fix. - Earlier, the fix shadowed over the stock jars: the minimal test passes with
-eaand-da; an independent reproducer's test 3 of 3 pass; per the second cross-model review,TestAddIndexes39 of 39 andTestIndexWriterDeletewith 0 failures. Not run: nightly tests, other modules.
In the synchronized block, wait while merges are off and re-check ensureOpen() after each wait; the call then runs after deleteAll() and succeeds, which the javadoc's "may" also allows.
Trade-offs
The caller blocks for the whole abort; deleteAll() re-enables merges without a notifyAll() (:2781), so the waiter relies on doWait()'s 1 s timeout unless one is added; and the call's documents then survive the deleteAll(), a semantic choice for the maintainers. Needs its own tests. By code reading, B would hang the minimal test (the held merge waits for the caller, the caller for merges to come back on, deleteAll() for the merge), so it needs a different test and an explicit liveness design.
Say that merges are being aborted by a concurrent deleteAll().
Trade-offs
Removes the false "closed" text but keeps a type that means "closed" for an open writer, so code that reacts to the type is still misled.
Downstream, by code reading only: in Solr a MERGEINDEXES request (which calls addIndexes(CodecReader[])) that overlaps a delete-all (*:* or clearIndex, which call deleteAll()) can get this misleading message; Elasticsearch and OpenSearch do not combine the two calls on one writer.
TLA+ model checking of the addIndexes(CodecReader...) merge path together with deleteAll()'s merge abort: the TLC model checker found a 4-step interleaving that breaks "AlreadyClosedException only when closing or closed", and none with the fix modelled. The trace was then replayed as a deterministic JUnit on the stock jar.
The method in full133 words
Method: formal, with a caveat. The model (AddIndexesMergeSource.tla) covers the merge-source protocol of addIndexes(CodecReader...), deleteAll()'s full-flush lock and abort wait loop, rollback and a running natural merge, with a model-to-Java line map. Configuration da1_ace (1 caller, ConcurrentMergeScheduler, deleteAll(), a natural merge) violated the invariant NoSpuriousACE with a 4-state trace (214 states generated, 120 distinct); da1_fixed (the fix modelled) found no error in 234 distinct states. The invariant names the symptom directly, so the hypothesis may have been formed while writing the model (semi-formal).
The finder replayed the trace as a JUnit with delay-only hooks; independent checks reproduced it with their own tests, and a hook-free stress run showed the window opens without hooks. The minimal test here is a latch-ordered rewrite (no sleeps) with one thread fewer.
No prior report found (2026-10-27, rechecked 2026-10-31): 232 tool queries and 50 manual GitHub searches over issues, PRs, commits, the JIRA archive, the lists, Solr, Elasticsearch, OpenSearch and the web, plus all 409 open PRs. Closest: the #633 review that chose this exception for a closing writer.
Searches4 searches
Verdict new. The exact message "Cannot execute addIndexes" occurs in no issue, PR, comment or commit message. Related: #633 / #11253 / #1051 (LUCENE-10216, the introducing change and its review), #10512 (LUCENE-9473, which made deleteAll()'s abort turn merges off, for another race), #11044 (LUCENE-10005, open: better AlreadyClosedException messages in general, for a writer that really was closed) and #10204 (LUCENE-9164: "If IndexWriter is closed or being closed, AlreadyClosedException is expected"). #15863 (open) is a different bug on the same entry path, a rollback race (LFV-184). Limits: keyword search; private reports are invisible.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (apache/lucene issues and PRs, commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, JIRA archive, web) | 2026-10-27 | three runs (code site, deleteAll trigger, generic 'AlreadyClosedException on an open writer'), 63 queries each, plus the finder's 43-query run | no report; all hits same_bug false |
| By hand: GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr), Solr JIRA | 2026-10-27 | 50 searches over issues, PRs, comments, commits and code; full reads of #633 (52 review comments, 39 reviews, 24 issue comments), #11253, #1051, #10512, #10349, #10403, #9829, #9738, #15863, #11044 | no report; the exact message in no issue, PR, comment or commit message |
| Open apache/lucene PRs | 2026-10-27 | changed files of all 409; the 16 touching IndexWriter.java grepped | none changes this check or deleteAll() |
| GitHub, read-only recheck | 2026-10-31 | IndexWriter.java on main, branch_11x, branch_10_6, branch_9_12; issues and PRs updated since 2026-10-27 on 'Cannot execute addIndexes', 'addIndexes deleteAll', 'MergeAbortedException addIndexes' | the check unchanged everywhere; nothing new |
Became a user-visible exception in 9.4.0: LUCENE-10216 (#633, 698f40ad51a, 2022; backport 5dd8e9bdc5a) turned assert merges.areEnabled() into this AlreadyClosedException. Every release 9.4.0 to 10.6.0 and main are affected; 9.3.0 is not. Not fixed upstream (2026-10-31).
Versions tested8 rows
Introduced by 698f40ad51a on main, apache/lucene#633 "LUCENE-10216: Use MergeScheduler and MergePolicy to run addIndexes(CodecReader[]) merges" (Vigya Sharma, merged by Michael McCandless, 2022-07-06; issue #11253), released through the branch_9x backport 5dd8e9bdc5a (#1051, 2022-08-01). A commit-level bracket isolates it: 9.3.0 jars plus that commit's six core files throw the exception, the same files at its parent do not.
Older state. LUCENE-9473 (#10512, 2020, 8.7.0) made deleteAll()'s abort turn merges off, so the same overlap reached the line from 8.7.0 on, where it only tripped the internal assert merges.areEnabled() with assertions on and had no effect with assertions off. #15025 (10.3.0) moved the flush to the start of addIndexes(CodecReader...), which only reshaped the window.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails with assertions on and off (gradle and standalone); passes with the fix |
| 10.6.0, 10.5.2 | affected | the minimal test fails with assertions on and off |
| 10.0.0, 9.12.3 | affected | the minimal test fails (assertions on) |
29 releases 9.4.0 to 10.5.2, main a9779ef3c193 | affected | the finder's repro throws the exception with assertions on and off |
9.3.0 + 5dd8e9bdc5a | shadowed files | throws it; the same files at 5dd8e9bdc5a^ behave like 9.3.0 |
| 8.7.0, 8.11.4, 9.0.0-9.3.0 | assertion only | no AlreadyClosedException: an internal AssertionError with assertions on; the call returns with them off |
| 8.5.2, 8.6.3 | not affected | the call returns normally |
upstream main 2f424fd428da, branch_11x, branch_10_6, branch_9_12 | read | the check is unchanged (2026-10-31) |
Confirmed by an independent reproducer, a devil's-advocate verifier with a hook-free stress, an adjudication and a credibility audit, each with its own runs. Of cross-model reviews (OpenAI GPT-5.6), one said real; the other reproduced it but called it intended. This doc's gradle run reproduced it and validated the fix.
Checks and verdicts315 words
Every executed check reproduced the exception on an open writer. The one not-real verdict agreed with the root cause and argued intent from the #633 review (see the devil's advocate above).
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+ model, then JUnit) | candidate | TLC: NoSpuriousACE violated in a 4-state trace, no error with the fix modelled; JUnit fails on main -ea/-da, 10.5.2 and 9.4.0, passes on 9.3.0 and with the fix. |
| Cross-model review, refute lens (OpenAI GPT-5.6, executed) | not real (medium) | Reproduced it with its own test and agreed on the root cause; rated the intended-behaviour case (the #633 choice) strong. |
| Independent reproducer | real | Own latch-driven test fails 30 of 30 with -ea and with -da, and on 10.5.2, 9.12.3, 9.4.0; a caller one step further along (its own merge running) gets MergeAbortedException; with no running merge the call succeeds. |
| Cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Intended case moderate; own test sees the exception on an open writer that accepts a retry; with the fix TestAddIndexes 39/39 and TestIndexWriterDelete 0 failures. |
| Intended-behaviour devil's advocate | real (medium-high) | Re-ran the finder's repro (fails); hook-free stress: 4-5 false "closed" exceptions per 227-252 deleteAll() calls. |
| Adjudication | confirmed (medium) | Re-ran the independent reproduction's and the finder's tests with a new seed: fail; "a difference over severity, not over whether the bug is real". |
| Credibility audit | keep | Re-ran the finder's test (fails); hook-free stress: 9 against 107 MergeAbortedExceptions in 277 deleteAll() calls. |
| Repro minimization | verified | The one-method test fails on 645d4fe14a20 with -ea and -da, 10 of 10 iterations; with the fix shadowed it passes. |
| This doc's brief | real; fix validated | Gradle in a scratch worktree: stock fails, fix passes, 252 tests of the closest suites pass; the minimal test fails on 10.6.0, 10.5.2, 10.0.0 and 9.12.3. |
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 487 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,366 words
- Registry title
- addIndexes(CodecReader...) throws AlreadyClosedException 'this IndexWriter is closed' on an open writer when it overlaps a deleteAll() that is waiting for a running merge to abort, although deleteAll's javadoc says such callers may receive MergeAbortedException
- Area
- index / IndexWriter addIndexes(CodecReader...) entry check vs deleteAll (abortMerges)
- Priority
- Low, rank 845: Only a wrong exception type for one call in a rare deleteAll plus addIndexes(CodecReader) overlap with nothing lost, so it follows the addIndexes failed-call rows, including LFV-629, which discards a completed merge.
- Impact class
- wrong error
- Impact note
- Hits applications that call deleteAll() on an IndexWriter while some merge is running and, on another thread, addIndexes(CodecReader...) on the same writer (default IndexWriterConfig, ConcurrentMergeScheduler and TieredMergePolicy; no scale needed): the overlapping call is rejected either way, and the defect is the exception type and its false message, since the writer is open, deleteAll returns normally, a retry on the same writer succeeds and nothing is lost or damaged [exec]; the possible downstream harm, an application that treats AlreadyClosedException from IndexWriter as 'writer gone' and discards a healthy writer, was not executed; every release 9.4.0-10.6.0 and main, -ea and -da alike [exec].
- Summary
- One thread calls deleteAll() on an IndexWriter while a merge is running, and another thread calls addIndexes(CodecReader...) on the same writer. You expect the overlapping call either to succeed or to fail with MergePolicy.MergeAbortedException, as deleteAll's javadoc says for concurrent addIndexes(CodecReader[]) callers and as most such overlaps do. Instead, when the call reaches its entry check while deleteAll is waiting for the running merge to abort, it throws AlreadyClosedException 'this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API' although the writer is open: isOpen() is true, deleteAll returns normally and the next addIndexes call on the same writer succeeds.
- Introduced
- 698f40ad51af0c42b0a4a8321ab89968e8d0860b on main ('LUCENE-10216: Use MergeScheduler and MergePolicy to run addIndexes(CodecReader[]) merges. (#633)', by vigyasharma, merged by mikemccand; issue #11253), released through the branch_9x backport 5dd8e9bdc5ae72fc726a98a64bfce5119c77b558 (#1051). It replaced 'ensureOpen(); assert merges.areEnabled();' at this spot with 'ensureOpen(); if (merges.areEnabled() == false) throw new AlreadyClosedException("this IndexWriter is closed. Cannot execute addIndexes(CodecReaders...) API")' [read: git blame on main a9779ef3c193 attributes IndexWriter.java:3300-3303 to 698f40ad51a and no later commit touched them; exec: commit level, 9.3.0 jars plus the six core files of 5dd8e9bdc5a throw the AlreadyClosedException under -ea and -da, the same files at 5dd8e9bdc5a^ (byte-identical to 9.3.0) do not]. The reachable state (an open writer whose merges are disabled during deleteAll's abort wait) is older: LUCENE-9473 (8480329213b on master, a07da4a3193 on branch_8x, 2020-08-24, first released in 8.7.0) made abortMerges() disable merges for deleteAll too, and from 8.7.0 to 9.3.0 the same interleaving only trips the internal 'assert merges.areEnabled()' under -ea and has no effect under -da [exec] (2022-07-06 on main (698f40ad51a); 2022-08-01 on branch_9x (5dd8e9bdc5a))
- First affected version
- 9.4.0 [exec: the canonical repro and the history variant fail under -ea and -da; commit-level bracket on 9.3.0 jars]; 9.3.0 is the last release without the AlreadyClosedException [exec: under -ea the same interleaving throws the internal AssertionError of 'assert merges.areEnabled()' (IndexWriter.java:3195), under -da the call returns normally]
- Affected versions
- every release from 9.4.0 through 10.6.0 and main a9779ef3c193 and 645d4fe14a20 [exec: the minimal test fails on the 10.6.0 release jars and on main 645d4fe14a20 under -ea and -da (2026-10-31); the canonical repro fails on all 29 releases 9.4.0, 9.4.1, 9.4.2, 9.5.0-9.8.0, 9.9.0-9.9.2, 9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2 and on main, under -ea and -da (60 runs); the independent reproduction's independent test fails on 10.5.2, 9.12.3, 9.4.0 and main under -ea and -da]. Not affected [exec]: 9.1.0, 9.2.0, 9.3.0 (canonical repro) and 8.5.2, 8.6.3, 8.7.0, 8.11.4, 9.0.0 (history variant); from 8.7.0 to 9.3.0 the overlap trips an internal AssertionError under -ea only. Not fixed upstream [read]: upstream main head a95c754ee003, branch_10x, branch_10_6 and branch_9_12 still have the check, and no open PR changes it.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- formal: TLA+/TLC
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record verdict new, all hits same_bug false): three scripted prior-report searches (63 queries each over apache/lucene issues and PRs, commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; focus on the code site, on the deleteAll trigger and on the generic 'AlreadyClosedException on an open writer' symptom) plus the finder's 43-query run; 50 manual GitHub searches (issues, PRs, comments, commits and code in apache/lucene, Elasticsearch, OpenSearch and Solr; the exact message 'Cannot execute addIndexes' occurs in no issue, PR, comment or commit message); Solr JIRA; the touched files of all 409 open apache/lucene PRs (16 touch IndexWriter.java; none changes the addIndexes entry check or deleteAll); full reads of #633 (52 review comments, 39 reviews, 24 issue comments), #11253, #1051, #10512, #10349, #10403, #9829, #9738, #15863, #11044 and others; git history. Related, not this bug: #633 / #11253 / #1051 (LUCENE-10216, the introducing change; in the #633 review mikemccand wrote of this check 'I think this only happens if IW is aborting/closing? Maybe throw AlreadyClosedException instead?' and vigyasharma switched to it, deleteAll was never mentioned); #10512 (LUCENE-9473, which made deleteAll disable merges during its abort wait, for a different race); #10349 (LUCENE-9309); #11044 (LUCENE-10005, open: better AlreadyClosedException messages in general, its example a writer that really was closed); #15863 (open, the CI symptom of LFV-184, a rollback race on the same entry path); #10204 (LUCENE-9164: 'If IndexWriter is closed or being closed, AlreadyClosedException is expected'). Not fixed upstream: main head a95c754ee003, branch_10x, branch_10_6 and branch_9_12 still have the check.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree and standalone; with the recommended fix it passes, and 252 tests in the 6 closestIndexWritersuites pass. Release jars: 10.6.0 and 10.5.2 fail with assertions on and off, 10.0.0 and 9.12.3 with assertions on. The hunt ran on maina9779ef3c193, whereIndexWriter.javais identical; upstream main2f424fd428da, branch_11x, branch_10_6 and branch_9_12 still have the check (read, 2026-10-31).
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-168 (completely thread safe), :905-911 (ensureOpen javadoc: AlreadyClosedException if closed or closing), :2621 (rollback's abortMerges, after closing is set), :2743-2745 (deleteAll javadoc: concurrent addIndexes(CodecReader[]) callers may receive MergeAbortedException), :2771-2781 (deleteAll: abortMerges, merges.enable() in finally), :2821-2861 (abortMerges: merges.disable() at :2823, wait loop releasing the monitor at :2849-2860), :3275-3304 (addIndexes(CodecReader...): flush, testReserveDocs, then ensureOpen and the AlreadyClosedException at :3298-3304), :5165-5167 (registerMerge throws MergeAbortedException in the same merges-disabled state)lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1235-1253 (CommitAndAddIndexes3.handle: AlreadyClosedException and MergeAbortedException from concurrent addIndexes are tolerated only when didClose is true)lucene/core/src/java/org/apache/lucene/store/AlreadyClosedException.java:20-23 (javadoc: 'thrown when there is an attempt to access something that has already been closed'; extends IllegalStateException); lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:573-577 (MergeAbortedException: 'Thrown when a merge was explicitly aborted because IndexWriter#abortMerges was called', extends IOException)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1331, (at 645d4fe14a20) :1337, :2582, :6104 (rollbackInternal callers, each after shouldClose), :1379 (shouldClose sets closing), :2601-2625 (rollbackInternalNoCommit, abortMerges at :2621), :2750-2815 (deleteAll: abortMerges :2771, merges.enable() in finally :2781), :2821-2869 (abortMerges: merges.disable() :2823, setAborted :2839-2844, wait loop :2849-2860), :3275-3304 (addIndexes(CodecReader...) flush, numDocs, testReserveDocs, the check), :3307, :3356, :3377-3378, :3390-3392, :2463-2465 (updatePendingMerges), :3311, :3408-3417 (AddIndexesMergeSource.registerMerge, no merges check), :3426, :3443-3456 (abortPendingMerges), :3494, :5165-5167 (IndexWriter.registerMerge, regular merges), :5860-5872 (doWait), :6117-6120 (isOpen), :6893-6911 (Merges)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:167-168 (at 645d4fe14a20) (completely thread safe), :905-917 (ensureOpen)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2743-2745 (at 645d4fe14a20) (deleteAll javadoc), :905-912 (ensureOpen javadoc), :2463-2465, :3377-3378, :3408-3417, :5165-5167lucene/core/src/java/org/apache/lucene/store/AlreadyClosedException.java:19-23 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:961-973, (at 645d4fe14a20) TestIndexSorting.java, TestIndexWriterMaxDocs.java, TestDocValuesIndexing.java (grep of all modules: only TestIndexWriter's IndexerThreadInterrupt calls both, from one thread, with addIndexes(Directory...))
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2781, (at 645d4fe14a20) :3378, :5860-5872; lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:573-577 (at 645d4fe14a20)
- TestAddIndexesDuringDeleteAll.java (one test):
repro/LFV-632/src/org/apache/lucene/index/TestAddIndexesDuringDeleteAll.java(122 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-632/bug.png(487 KB)
