All readers of one segment share a cache of open doc-values producers, one per update generation. When the last reader of a generation closes, the producer is closed first and removed from the cache second, so a close that throws leaves the closed producer cached for the next reader.
r3 is handed it. Select the still to open it at full size. Generated with AI, checked against the code.- Keep old commits (
NoDeletionPolicy). Index two docs withn= 0 and 1 and commit; update doc 0 ton= 10 and commit again. The update lives in doc-values generation 1. - Open
r1on commit 1, thenr2 = openIfChanged(r1, commit 0).r2sharesr1's segment and its doc-values cache, but does not use generation 1. - Close
r1while one inputclose()throws anIOException. Lucene closes the generation-1 producer, but the throw skips removing it from the cache, and its reference count is put back to 1. - Reopen:
r3 = openIfChanged(r2, commit 1). You expect doc 0 to readn= 10, as a freshDirectoryReader.open(commit 1)does. - Instead
r3is handed the cached, closed producer:getNumericDocValues("n")throwsAlreadyClosedException: Already closed., and keeps throwing until every reader of the segment is closed.
Applications that keep older commits (NoDeletionPolicy, SnapshotDeletionPolicy) and move readers between them with openIfChanged(reader, commit), on a Directory whose input close can throw after closing its file (the stock MMapDirectory input close never does). The failure is loud and contained: no data changes, and a fresh open works. Contrived, so priority Low.
Mechanism, terms and numbers584 words
All readers of one segment core share one SegmentDocValues, a cache with one reference-counted doc-values producer per doc-values update generation. When the last reader of a generation lets go, release() closes the producer and only then removes the generation from the cache (SegmentDocValues.java:54-59 on main). If the close throws, the removal is skipped and RefCount.decRef puts the count back to 1 ("Put reference back on failure", RefCount.java:51-54). Nobody owns that count: the closing reader is already marked closed (IndexReader.java:224), so the release is never retried. The next reader of the same core that needs the generation, for example one returned by openIfChanged(reader, commit) onto a kept commit, gets the closed producer, and its doc-values reads that touch the closed files throw AlreadyClosedException (in the repro, the first read). A fresh DirectoryReader.open of the same commit reads correct values. It needs an input close that throws after closing its file, which the stock MMapDirectory input close never does, plus commit-point navigation with kept commits; it fails loudly and changes no data. The order dates from LUCENE-5189 (2013): every release from 4.6.0 to 10.6.0 and main are affected. The fix removes the cache entry before closing.
- Doc-values generation: each commit that carries doc-values updates for a segment writes them as a new generation of doc-values files (for example
_0_1_Lucene90_0.dvd, the file whose close failed in the independent reproducer's diagnostic run). A reader of that commit reads each field from the generations that hold its values: with main's default doc-values overlays (8,IndexWriterConfig.java:115),nis read from generation 1 (the sparse update) layered over generation-1, the segment's original doc values (SegmentDocValuesProducer.java:77-81,:182-186); with overlays off, as in 10.6.0, only from the generation that last rewrote it. SegmentDocValues: one per segment core, shared by everySegmentReaderof that core (SegmentReader.java:153,this.segDocValues = sr.segDocValues;). It maps each generation to aRefCount<DocValuesProducer>(SegmentDocValues.java:35-36).- Reopen across a doc-values-only change:
openIfChanged(reader, commit)builds the new segment reader from the old one (StandardDirectoryReader.java:328-337), so both draw from the same cache.
// SegmentDocValues.java:51-60
return new RefCount<>(dvFormat.fieldsProducer(srs)) {
protected void release() throws IOException {
object.close(); // :55 throws here...
synchronized (SegmentDocValues.this) {
genDVProducers.remove(gen); // :57 ...so this never runs
}
}
};
// RefCount.java:46-55 (condensed)
if (refCount.decrementAndGet() == 0) {
try { release(); }
catch (Throwable t) { refCount.incrementAndGet(); throw t; } // "Put reference back on failure"
}
// SegmentDocValues.java:64-75: a cached entry is incRef'd and returned without any checkr1.close()reachesSegmentReader.doClose(SegmentReader.java:214-225), which decRefs generation 1 once. The count hits 0,release()runs, andLucene90DocValuesProducer.close(:510-512,IOUtils.close(data, ...)) throws from the data input, which the repro's wrapper has already closed (IOUtils.closestill tries the other inputs).- The removal at
:57is skipped andRefCount.decRefrestores the count to 1.IndexReader.decRefsetclosed = truebeforedoClose(IndexReader.java:222-227), sor1can never retry: the count has no owner. openIfChanged(r2, commit 1)asks the shared cache for generation 1, finds the entry, incRefs it to 2 and returns the closed producer. The reader opens normally; its first doc-values read touches the closed data input and throws (a dense constant column would still read: it is served from metadata,Lucene90DocValuesProducer.java:895-902).
With the repro's plain directory the read fails in ByteBuffersIndexInput.ensureOpen with AlreadyClosedException: Already closed.; under the test framework's MockDirectoryWrapper the same bug shows as its use-after-close check, "Abusing closed IndexInput!".
Exposure and likelihood245 words
- Who is hit. After one failed close, every later reader of that segment core that needs the released generation is handed the closed producer, and its doc-values reads that touch the closed files fail (in the repro, the first read), until all readers of the core are closed.
- What triggers the failed close. A doc-values input
close()that throws while a reader closes, after it has really closed its file (the repro's wrapper closes its delegate, then throws). The stockMMapDirectoryinput close propagates no exception (MemorySegmentIndexInput.java:707-730retries until the arena closes);FSDirectory.openreturnsMMapDirectoryon 64-bit JREs andNIOFSDirectoryotherwise (FSDirectory.java:155-161).NIOFSDirectory(FileChannel.close,NIOFSDirectory.java:122-126) or a custom or filterDirectorycan throw there. A close that throws before closing its file may leave the producer usable or only partly closed (not tested). - Default vs opt-in. Every call is public, documented API; the deletion policy that keeps older commits and the commit navigation are the application's choice. No scale is needed: 2 documents and 1 update.
- Loud, contained. A clean
AlreadyClosedException, no data changed, no wrong results; a freshDirectoryReader.openof the same commit reads correct values. Before 6.6.0 the failedclose()itself returned normally, so the first sign was the later read failure. - Not claimed. Wrong results, corruption, and the NRT (
IndexWriterreader pool) path were not tested. - Likelihood: contrived. Two rare events in one process; no public report of it was found since 4.6.0 (2013).
One JUnit test, single thread, deterministic, public API only. A plain FilterDirectory over ByteBuffersDirectory makes the next input close() throw once, after really closing. Two docs and one doc-values update; it fails with assertions on and off on main, and on the 10.6.0 and 9.12.3 jars.
/*
* 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.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.FilterIndexInput;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.IOUtils;
public class TestReopenAfterFailedDocValuesClose extends LuceneTestCase {
public void testReopenAfterFailedDocValuesClose() throws Exception {
AtomicBoolean failNextClose = new AtomicBoolean();
Directory dir =
new FilterDirectory(new ByteBuffersDirectory()) {
@Override
public IndexInput openInput(String name, IOContext context) throws IOException {
return new FilterIndexInput(name, in.openInput(name, context)) {
@Override
public void close() throws IOException {
in.close();
if (failNextClose.getAndSet(false)) {
throw new IOException("simulated close failure");
}
}
@Override
public IndexInput clone() {
return in.clone();
}
};
}
};
IndexWriterConfig iwc =
new IndexWriterConfig()
.setCodec(TestUtil.getDefaultCodec())
.setIndexDeletionPolicy(NoDeletionPolicy.INSTANCE);
try (IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 2; i++) {
w.addDocument(
List.of(
new StringField("id", Integer.toString(i), Field.Store.NO),
new NumericDocValuesField("n", i)));
}
w.commit();
w.updateNumericDocValue(new Term("id", "0"), "n", 10);
w.commit();
}
List<IndexCommit> commits = DirectoryReader.listCommits(dir);
DirectoryReader r1 = DirectoryReader.open(commits.get(1));
DirectoryReader r2 = DirectoryReader.openIfChanged(r1, commits.get(0));
failNextClose.set(true);
expectThrows(IOException.class, r1::close);
DirectoryReader r3 = DirectoryReader.openIfChanged(r2, commits.get(1));
// expected: doc 0 reads n=10, as a fresh open of commit 1 does; actual: AlreadyClosedException
NumericDocValues n = r3.leaves().get(0).reader().getNumericDocValues("n");
assertTrue(n.advanceExact(0));
assertEquals(10, n.longValue());
IOUtils.close(r3, r2, dir);
}
}
cd $LUCENE
cp $REPRO/TestReopenAfterFailedDocValuesClose.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestReopenAfterFailedDocValuesClose -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
r3 = openIfChanged(r2, commit 1)readsn= 10 for doc 0, as a fresh open of commit 1 does.- Observed
r3's firstgetNumericDocValues("n")throwsAlreadyClosedException: Already closed.
More files, environment, full output181 words
Input: two docs, id = 0, 1 with n = 0, 1; commit 0; updateNumericDocValue(id:0, n, 10); commit 1. The test draws no random values. It uses two docs with distinct values: per the minimization notes, with one doc the generation-1 column is a constant and the read never touches the closed file. The fault wrapper's close() closes its delegate and then throws (lines 45-49); that order is what leaves the cached producer unusable. It belongs in lucene/core/src/test/org/apache/lucene/index/, next to TestDirectoryReaderReopen, and passes :lucene:core:check -x test.
The exception is thrown by getNumericDocValues("n") on r3, the first doc-values call, so the two assertions after it are never reached on stock code; with the fix they pass.
Regression test: this same test.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
TestReopenAfterFailedDocValuesClose > testReopenAfterFailedDocValuesClose FAILED
org.apache.lucene.store.AlreadyClosedException: Already closed.
at __randomizedtesting.SeedInfo.seed([DEADBEEF:365A404F094011A9]:0)
at org.apache.lucene.store.ByteBuffersIndexInput.ensureOpen(ByteBuffersIndexInput.java:223)
at org.apache.lucene.store.ByteBuffersIndexInput.slice(ByteBuffersIndexInput.java:62)
at org.apache.lucene.store.ByteBuffersIndexInput.slice(ByteBuffersIndexInput.java:28)
at org.apache.lucene.store.FilterIndexInput.slice(FilterIndexInput.java:97)
at org.apache.lucene.codecs.lucene90.IndexedDISI.createBlockSlice(IndexedDISI.java:376)
at org.apache.lucene.codecs.lucene90.IndexedDISI.<init>(IndexedDISI.java:304)
at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer.newIndexedDISI(Lucene90DocValuesProducer.java:277)
at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer.getNumeric(Lucene90DocValuesProducer.java:1084)
at org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer.getNumeric(Lucene90DocValuesProducer.java:781)
at org.apache.lucene.codecs.perfield.PerFieldDocValuesFormat$FieldsReader.getNumeric(PerFieldDocValuesFormat.java:318)
at org.apache.lucene.index.SegmentDocValuesProducer.getNumeric(SegmentDocValuesProducer.java:184)
at org.apache.lucene.index.CodecReader.getNumericDocValues(CodecReader.java:156)
at org.apache.lucene.index.TestReopenAfterFailedDocValuesClose.testReopenAfterFailedDocValuesClose(TestReopenAfterFailedDocValuesClose.java:81)openIfChanged(reader, commit)promises to "open and return a new reader"; a fresh open readsn= 10.- The cache is meant to drop a generation once no reader uses it;
release()does so only when the close succeeds. - A failed close still really closes (LUCENE-5553); earlier close failures here were fixed as bugs (LUCENE-7775, #13436).
The full argument238 words
- The contract.
openIfChanged(DirectoryReader, IndexCommit): "If the IndexCommit differs from what the provided reader is searching, open and return a new reader" (DirectoryReader.java:276-287); resources are shared "when possible" (:233-234). Nothing documents that sibling readers break after a failed close;IndexReader.closeonly says no other methods should be called on the closed reader (IndexReader.java:345-346), and the repro never touchesr1again. - The cache's design. A design comment in the LUCENE-5189 discussion (#6253) plans for
SegmentDocValuesto drop a generation once no reader uses it: "If it also removes a gen from the map when it's no longer referenced by any SR, we don't need to take care of clearing the genDVP map when all SRs were closed";release()does so, but only when the close succeeds. - Lucene's close policy. LUCENE-5553 (#6615, 4.7.1) made a failing reader close still close everything and dropped the reference put-back from
IndexReader.decRef, with maintainers agreeing on "close really closes and throws first exc" behaviour.RefCountkept the same put-back one level down. - Precedents and tests. LUCENE-7775 (#8826:
decRefdropped every close exception because of an inverted null check; fixed to throw the first one) and #13436 (later producers were not closed after a failing one) fixed other close failures inSegmentDocValues.decRefas bugs.TestDirectoryReaderReopen.testOverDecRefDuringReopenasserts that the old reader keeps working after its reopen fails on an I/O error (an open failure there, not a close failure).
The devil's-advocate check135 words
The strongest case (the devil's-advocate verifier's steelman): RefCount.decRef puts the reference back on purpose ("Put reference back on failure"), which reads as retry-later semantics, so keeping the entry after a failed release is by design. TaxonomyReader.decRef (lucene/facet) still has the same put-back.
Why it fails: nothing can retry here. The idiom mirrors the IndexReader.decRef of 2013, and LUCENE-5553 removed it there on purpose; the closing reader is marked closed before doClose, so it can never retry (TaxonomyReader.decRef sets closed only after doClose succeeds, so a retry is possible there); SegmentDocValues is the only Lucene user of this RefCount; and no javadoc or test documents the put-back (RefCount.java:22-45). The cross-model review (OpenAI GPT-5.6) rated the intended-behaviour case weak. What survives is a severity argument (rare trigger, loud failure), hence priority Low.
Recommended Remove the cache entry, then close (two lines moved)
Remove the generation from the cache before closing the producer: swap the two statements in SegmentDocValues.release(). A failed close then leaves no cached entry, and the next reader opens a fresh producer. Validated: the repro passes with assertions on and off; 11 related index suites (226 tests) and :lucene:core:check -x test pass.
protected void release() throws IOException {
- object.close();
+ // drop the cache entry first, so that a failed close cannot leave a closed producer cached
synchronized (SegmentDocValues.this) {
genDVProducers.remove(gen);
}
+ object.close();
}All options and trade-offs3 options ยท 426 words
Validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03). Workaround until fixed: after a reader close fails, open the commits you need with DirectoryReader.open(commit) instead of openIfChanged from a reader of the same segment core, or close all readers of that core first.
A failed close leaves no entry behind, so the next request for the generation opens a fresh producer. This matches the LUCENE-5189 intent that a generation leaves the map once no reader references it.
protected void release() throws IOException {
- object.close();
+ // drop the cache entry first, so that a failed close cannot leave a closed producer cached
synchronized (SegmentDocValues.this) {
genDVProducers.remove(gen);
}
+ object.close();
}Trade-offs
The success path is unchanged: release() runs only from SegmentDocValues.decRef, which holds the same lock as getDocValuesProducer (SegmentDocValues.java:64, :78), so no thread sees the in-between state. The half-closed producer is dropped rather than retried, which is no worse than today: nothing retries it now either. Its RefCount keeps the restored count of 1 but is no longer in the cache. Not explored: partly closed inputs, repeated close failures, file handles a failed close leaves open.
What the checks validated
- Stock
5adff78bb168: the repro fails through gradle with assertions on and off; with the fix it passes, on and off. - With the fix, 11 suites (the repro,
TestDirectoryReaderReopen,TestDirectoryReader,TestSegmentReader,TestNumericDocValuesUpdates,TestBinaryDocValuesUpdates,TestMixedDocValuesUpdates,TestSortedNumericDocValuesUpdates,TestIncrementalDocValuesUpdates,TestIndexWriterOnError,TestIndexWriterExceptions): 226 tests, 0 failures, 6 skipped. :lucene:core:check -x test(format, lint, forbidden APIs) with the fix and the test: passes.- Earlier, with the same reorder shadowed in, the finder's, the independent reproducer's and the cross-model reviewer's tests all passed, and the adjudication's re-run of the independent reproducer's test passed.
try { object.close(); } finally { synchronized (SegmentDocValues.this) { genDVProducers.remove(gen); } }. Same effect as the reorder.
Trade-offs
Slightly longer; not run.
What the checks validated
Not run (equivalent by reading).
Mirrors LUCENE-5553 for IndexReader. On its own it does not fix the bug: the entry would stay cached with count 0, and getDocValuesProducer would incRef it to 1 and hand out the closed producer.
Trade-offs
RefCount is a public class in org.apache.lucene.util; only worth doing together with the reorder, for consistency.
What the checks validated
Not run (insufficient by reading).
Downstream products are exposed only if they keep older commits and reopen readers onto them with openIfChanged(reader, commit) over a Directory whose input close can throw; no query or document can trigger it.
A proof-obligation audit of SegmentDocValues's reference counting asked what a release that throws leaves in the shared cache, and found the closed producer stays cached with no owner. A public-API JUnit test then witnessed it on main; independent checks reproduced it with their own tests.
The method in full126 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The obligation: a producer whose release fails must not stay in the per-generation cache that the other readers of the segment core draw from.
release()closes before it removes (SegmentDocValues.java:55-57), andRefCount.decRefrestores the count on failure (RefCount.java:51-54), so the obligation is not met. - A JUnit test using a test-framework fault at the generation-1 input close witnessed it on main (assertions on and off, three seeds) and on 10.6.0 and 9.12.3. An independent reproducer, an executed review by another model family (OpenAI GPT-5.6) and a devil's-advocate verifier wrote their own tests from the claim alone; the one-test plain-directory repro was cut from the independent reproducer's test on 2026-11-03.
No prior report: two scripted prior-art runs and 37 manual searches over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web, plus the changed files of all 410 open PRs (2026-11-03), rechecked the same day.
Searches3 searches
No report found in the documented searches says that a failed doc-values producer close leaves the closed producer in SegmentDocValues' cache, or that a reader reopened later is handed it. Related, not this bug: #6253 (LUCENE-5189, introduced the cache; its 120 comments never discuss a failing close), #6615 (LUCENE-5553, "close really closes" for IndexReader), #8826 (LUCENE-7775) and #13436/#13434 (other close failures in SegmentDocValues.decRef), #6718 (LUCENE-5656, a failure while opening a producer). Open PRs #16872, #16864, #16923 and #16847 touch neighbouring files but not the cache, decRef or release(). Gaps: keyword search; GitHub search does not index review comments; two web queries were throttled.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA archive; web | 2026-11-03 | two scripted prior-report search runs (54 and 86 hits) and 37 manual GitHub searches | only the related items above |
| Open PRs | 2026-11-03 | the changed files of all 410 open apache/lucene PRs; diffs of those touching nearby files | none changes SegmentDocValues.java or RefCount.java |
| Recheck (GitHub) | 2026-11-03 | class, method and symptom queries; PRs updated since 2026-11-02; upstream file history | nothing new; upstream main 1c2072f0284 still closes before removing |
Introduced by LUCENE-5189 (d37b2cb90b1, 2013-10-24), which moved the per-reader generation map into the shared SegmentDocValues; first released in 4.6.0. Executed: 4.6.0 to 10.6.0 and main fail; the parent commit passes. Silent at close until 6.6.0. Not fixed upstream.
Versions tested8 rows
Introduced by trunk d37b2cb90b1, "LUCENE-5189: factor out SegmentDocValues from SegmentReader" (Shai Erera, 2013-10-24; svn era, no pull request; JIRA LUCENE-5189 is #6253). Before it, each SegmentReader kept its own generation map, which its doClose cleared; the commit moved the map into SegmentDocValues, shared by all readers of a core, and wrote the close-then-remove order. The put-back in RefCount.decRef came with 1120e46961b (2013-09-15). Released through the branch_4x merge 49684b82701 (2013-11-01) in 4.6.0, the first release with doc-values updates. lucene-core built from source at the parent b6eab2b39aa passes the history probe and d37b2cb90b1 fails it. In 4.6.0-6.5.1 SegmentDocValues.decRef swallowed the exception, so close() returned normally; LUCENE-7775 (#8826) made it throw from 6.6.0 and 7.0.0. The probe's older forward route (commit 1, then 2, close, back to 1) fires on every release tested (4.6.0 to 10.6.0) but, on main, only with setMaxDocValuesOverlays(0): main's default is 8 overlays (10.6.0's is 0), which keep generation 1 referenced on that route; the repro's two-commit route fires with main's defaults. Upstream main 1c2072f0284 (2026-11-02) is unchanged here. All runs used JDK 25.0.3; the two 2013 jars were source builds.
| Version or commit | Verdict | Result |
|---|---|---|
| 4.5.1 and earlier | no API | no doc-values updates, no SegmentDocValues or RefCount (the probe does not compile) |
| trunk b6eab2b39aa (parent) | not affected | the probe passes: r3 reads 10 |
| trunk d37b2cb90b1 | introduced | the probe fails with AlreadyClosedException |
| 4.6.0 | first: 4.6.0 | the probe fails (assertions on and off); the failed close returns normally |
| 4.10.4, 5.5.5, 6.5.1 | affected | the probe fails; the failed close returns normally |
| 6.6.0, 6.6.6, 7.7.3, 8.11.4 | affected | the probe fails; the failed close throws |
| 9.12.3, 10.5.2, 10.6.0 | affected | the JUnit tests fail; this doc's one-test repro fails on 9.12.3 and 10.6.0 |
| main 5adff78bb168 | affected | the one-test repro fails through gradle, assertions on and off; passes with the fix |
An independent reproducer, an executed review by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (high confidence, priority Low). This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts249 words
The cross-model review ran on main ea57360912ed, whose relevant files equal 5adff78bb168's, and its test was re-run on 5adff78bb168 with the same result; every other check ran on 5adff78bb168 or a release.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Test-framework fault at the input close: fails on main (on, off; three seeds), 10.6.0 and 9.12.3 with "Abusing closed IndexInput!"; the reorder passes it. |
| independent reproducer | real, Low | Own plain-FilterDirectory test fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3 with AlreadyClosedException; a fresh open reads 10; the reorder passes it. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own two-test JUnit: the fault test fails (on, off), the no-fault control passes, the reorder passes; intended-behaviour argument rated weak. |
| devil's-advocate verifier | real, Low | Steelmanned the put-back as retry-later semantics and rebutted it (LUCENE-5553, no retry path, undocumented); re-ran the test on main and 10.6.0: fail. |
| adjudication | confirmed (high); Low | Re-ran the independent test with a new seed (fail) and with the fix (pass); not a duplicate of LFV-109 (different cause and fix site). |
| separate re-run, audit and final repro check | reproduced; confirmed, Low | Fresh seeds fail on and off; the one-test repro fails on and off and passes with the fix. |
| this doc | reproduced, fix validated | Gradle on 5adff78bb168 (fail, on and off; pass with the fix); 10.6.0 and 9.12.3 jars (fail); 11 suites and check -x test with the fix (pass). |
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 548 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,584 words
- Registry title
- After one failed close of a doc-values producer, SegmentDocValues keeps the closed producer in its per-generation cache, so a reader later reopened onto a commit that uses that doc-values generation is handed the closed producer and every doc-values read on it throws AlreadyClosedException
- Area
- index / SegmentDocValues (per-generation doc-values producer cache shared by the SegmentReaders of one segment core; reopen with openIfChanged(reader, commit))
- Priority
- Low, rank 609: After one failed doc-values input close, a reader reopened onto a kept commit is handed a closed producer and every doc-values read on it throws AlreadyClosedException, in every release since 4.6.0. It needs that close failure, which stock MMapDirectory never produces, plus commit-point navigation with retained commits, and it fails loudly with no data changed, so it is contrived.
- Impact class
- minor
- Impact note
- Hits only applications that keep older commits (NoDeletionPolicy, SnapshotDeletionPolicy or a custom deletion policy) and move readers between commits with DirectoryReader.openIfChanged(reader, commit), and only after closing one reader failed with an exception from a doc-values input close (stock MMapDirectory input closes do not throw; a channel-based, custom or filter Directory whose close can fail, or any Throwable from the producer close, is needed): every later reader of the same segment core that needs the released doc-values generation is handed the closed producer and every doc-values read on it throws AlreadyClosedException until all readers of that core are closed; a fresh DirectoryReader.open of the same commit reads correct values, no index data changes, no scale is needed, and every release since 4.6.0 is affected.
- Summary
- You keep older commits, open a reader r1 on a commit whose segment has doc-values updates, reopen it onto an older commit as r2 (r2 shares r1's segment core but not the updated doc-values generation), and closing r1 fails with an I/O error from that generation's doc-values input. You expect r2, and any reader later opened from it, to keep working: openIfChanged(r2, newer commit) should return a usable reader of that commit, just as a fresh open of the commit does. Instead SegmentDocValues closed the producer before removing it from its cache and RefCount put the reference back when the close threw, so the reopened reader is handed the closed producer and its first doc-values read throws AlreadyClosedException ('Already closed.').
- Introduced
- d37b2cb90b12 on trunk (svn r1535526), 'LUCENE-5189: factor out SegmentDocValues from SegmentReader' by Shai Erera (shaie): it moved the generation-to-RefCount producer map from each SegmentReader into SegmentDocValues, which all SegmentReaders of one core share, and wrote the RefCount's release() as close, then remove from the map; the released line got it through the branch_4x merge 49684b82701 (svn r1537832, 'LUCENE-5189: add NumericDocValues field updates'). RefCount.decRef's 'Put reference back on failure' came earlier with trunk 1120e46961b (LUCENE-5189 'add NumericDocValues updates', 2013-09-15). No pull request (pre-GitHub; JIRA LUCENE-5189, migrated as #6253) [exec: commit-level A/B with lucene-core built from source at each commit: the parent b6eab2b39aa passes, d37b2cb90b1 fails] [read: git blame -w -M -C on 5adff78bb168, git log -S 'genDVProducers.remove' finds only d37b2cb90b1] (2013-10-24 (d37b2cb90b1, trunk, author date = commit date); 2013-11-01 (branch_4x merge 49684b82701))
- First affected version
- 4.6.0 [exec] (tagged 2013-11-22; the first release with doc-values updates at all); 4.5.1 and earlier lack the code: no IndexWriter.updateNumericDocValue, SegmentDocValues or RefCount [exec: the probe does not compile; jar listing, javap]
- Affected versions
- every release from 4.6.0 through 10.6.0, and main 5adff78bb168 [exec: a main() probe (forward three-commit route, production MMapDirectory under a FilterDirectory whose generation-1 .dvd input close throws once) fails on 4.6.0 (-ea, -da), 4.10.4, 5.5.5, 6.5.1, 6.6.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3 and 10.6.0 (-ea, -da), and its no-fault control passes on 4.6.0 and 10.6.0; the JUnit tests (two-commit route) fail on 9.12.3, 10.5.2 (the independent reproduction's test), 10.6.0 and main (-ea, -da)] [read: git tag --contains lists 113 release tags from 4.6.0 to 10.6.0 in the history clone, which lacks the 8.9.0-8.11.x tags (8.11.4 was executed); release() reads close-then-remove at every tag checked]. In 4.6.0-6.5.1 r1.close() returned normally because SegmentDocValues.decRef swallowed the exception (fixed by LUCENE-7775 in 6.6.0 and 7.0.0), so the failed close was silent there; from 6.6.0 it throws; the cached closed producer is the same in both. On main the default doc-values overlays keep generation 1 referenced on the forward route (it fires there only with setMaxDocValuesOverlays(0)); the two-commit route fires with main's defaults. Not fixed upstream (main tip 1c2072f0284, 2026-11-02; the newest commits on SegmentDocValues.java and RefCount.java are dbaf97f5dcc, 2025-07-18, and 27d1272984f, 2025-07-14, author date = committer date, neither changes the order). Not new in 10.6 (10.5.2 and 9.12.3 fail). All runs used JDK 25 on one aarch64 Linux host; the two 2013 trunk jars were built from source (lucene-core only, javac --release 8, one build-only patch to a constructor failure path the probe never reaches), not official builds
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: proof-obligation audit
- Upstream and prior reports
- none found (2026-11-03, on top of the finder's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (the finder's: 45 queries, 54 hits; the follow-up run: 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@ and 4 JIRA-archive queries, 86 hits, none high), 37 manual GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr and all of GitHub), commit search, the git history of both fix-site files, manual list and JIRA-archive searches, two web queries (two more were throttled), the changed files of all 410 open apache/lucene PRs with the diffs of those touching SegmentReader.java, StandardDirectoryReader.java, SegmentDocValuesProducer.java or SegmentCoreReaders.java, and the 34 PRs updated since 2026-11-02 (file lists read): no issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports a closed producer left in SegmentDocValues' cache after a failed close, or a reopened reader that is handed it. Nearest items, none of them this bug: #6253 (LUCENE-5189, introduced the cache; its 120 comments never discuss a failing close), #6615 (LUCENE-5553, made a failing IndexReader close still close the reader; did not touch RefCount or release()), #8826 (LUCENE-7775) and #13436/#13434 (other close-failure defects in SegmentDocValues.decRef), #6718 (LUCENE-5656, a failure while opening a producer), open #16872 (which generations a reader maps), open #16864, #16923 and #16847 (touch SegmentReader.java or StandardDirectoryReader.java; no changed line touches the producer cache, decRef or release).
- Lucene commit tested
- main
5adff78bb168(the one-test repro through gradle in a clean scratch worktree, assertions on and off, unfixed and fixed; 11 relatedorg.apache.lucene.indexsuites and:lucene:core:check -x testwith the fix, 2026-11-03); the 10.6.0 and 9.12.3 release jars (the same test, assertions off). Earlier checks ran on main5adff78bb168(the cross-model review onea57360912ed, whose relevant files are identical, then re-run on5adff78bb168), and a history probe on releases from 4.6.0 to 10.6.0 and on two source-built 2013 commits.
- Finding record
lucene/core/src/java/org/apache/lucene/index/SegmentDocValues.java:51-60 (at 5adff78bb168) (newDocValuesProducer returns a RefCount whose release() runs object.close() at :55 and only then genDVProducers.remove(gen) at :56-58), :64-75 (getDocValuesProducer: a cached entry is incRef'd and returned without any check), :78-86 (decRef over the reader's generations via IOUtils.applyToAll)lucene/core/src/java/org/apache/lucene/util/RefCount.java:46-60 (at 5adff78bb168) (decRef: when release() throws, 'Put reference back on failure' restores the count at :51-53 and rethrows)lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:150-153 (at 5adff78bb168) (a SegmentReader built from another one shares its core and its SegmentDocValues, 'this.segDocValues = sr.segDocValues;'), :214-224 (doClose decRefs its doc-values generations)lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:328-337 (at 5adff78bb168) (reopen onto a commit whose segment differs only by doc-values updates builds new SegmentReader(commitInfo, oldReader, ...), which shares the cache)lucene/core/src/java/org/apache/lucene/index/IndexReader.java:215-232 (at 5adff78bb168) (decRef sets closed = true at :224 before doClose runs, so a failed close is never retried and the restored count has no owner)lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:276-282 (at 5adff78bb168) (openIfChanged(DirectoryReader, IndexCommit): 'open and return a new reader')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/SegmentDocValues.java:35-36, (at 5adff78bb168) :51-60, :64-75, :78-86lucene/core/src/java/org/apache/lucene/util/RefCount.java:46-60 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/index/SegmentReader.java:150-153, (at 5adff78bb168) :214-225lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:328-337 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/index/IndexReader.java:215-232 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:510-512 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:37-39, (at 5adff78bb168) :77-81, :182-186 (overlay layers per field)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:115 (at 5adff78bb168) (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 8); a Lucene clone releases/lucene/10.6.0 IndexWriterConfig.java:116 (0)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:895-902 (at 5adff78bb168) (dense constant column served from entry.minValue)
- Who is affected
lucene/core/src/java/org/apache/lucene/store/MemorySegmentIndexInput.java:707-730 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/store/NIOFSDirectory.java:122-126 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:155-161 (at 5adff78bb168) (MMapDirectory on 64-bit JREs, else NIOFSDirectory)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:227-244, (at 5adff78bb168) :276-287lucene/core/src/java/org/apache/lucene/index/IndexReader.java:215-232, (at 5adff78bb168) :340-346lucene/core/src/java/org/apache/lucene/util/RefCount.java:22-60 (at 5adff78bb168)lucene/core/src/test/org/apache/lucene/index/TestDirectoryReaderReopen.java:673-733 (at 5adff78bb168) (testOverDecRefDuringReopen: a readLiveDocs open failure during reopen; the old reader still searches)lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyReader.java:154-169 (at 5adff78bb168) (put-back with closed set only after doClose succeeds)GitHub #6253 (read-only, 2026-11-03): the design comment of 2013-10-06 quoted above
- Suggested fix
lucene/core/src/java/org/apache/lucene/index/SegmentDocValues.java:64, (at 5adff78bb168) :78
- History
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:115; (at 5adff78bb168) a Lucene clone releases/lucene/10.6.0 IndexWriterConfig.java:116 (DEFAULT_MAX_DOC_VALUES_OVERLAYS)
- Diagrams
lucene/core/src/java/org/apache/lucene/index/SegmentDocValues.java:51-75 (at 5adff78bb168)
- TestReopenAfterFailedDocValuesClose.java (one test, lucene/core):
repro/LFV-1217/TestReopenAfterFailedDocValuesClose.java(86 lines)
A step-by-step picture of the cache entry for generation 1, made in parallel with this doc from the code at 5adff78bb168 and the executed repro; the PNG is the summary still. Both follow the repro's two-doc example. The diagram's repro line numbers count from the test without its 16-line license header: its lines 59-65 and 67 are lines 75-81 and 83 of the file shipped here.
assets/LFV-1217/bug.png(548 KB)
