All findings · Lucene finding · LFV-1217 · segmentdocvalues-failed-close-keeps-closed-producer-cached

After one failed doc-values close, a reopened reader is handed the closed producer

When closing a reader fails on a doc-values file, the segment's shared doc-values cache keeps the closed producer. A reader reopened later onto a commit that needs it opens normally, but reads from the closed files throw AlreadyClosedException; every release since 4.6.0 is affected, and the fix swaps two statements.

Summary

Priority
Low, rank 609After 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
minor
Since
4.6.0 (2013, the first release with doc-values updates)introduced 2013-10-24 · over 13 years
Affected
4.6.0 to 10.6.0 and main
Status
documented
Found by
a proof-obligation audit of SegmentDocValues's reference-counting contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

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.

Diagram of one segment's shared doc-values cache. Reader r1 on commit 1 holds generation 1. Reader r2, reopened onto commit 0, shares the cache and holds only the base generation. r1.close() closes the generation-1 producer, the close throws, the cache entry is not removed and its count is put back to 1. Reader r3, reopened onto commit 1, is handed the closed producer and its first doc-values read throws AlreadyClosedException; expected: doc 0 reads n = 10. Fix: remove the entry before closing.
The shared doc-values cache through the repro: the failed close leaves generation 1's producer closed but still cached, with its count put back to 1 and no owner, and the reopened reader r3 is handed it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Keep old commits (NoDeletionPolicy). Index two docs with n = 0 and 1 and commit; update doc 0 to n = 10 and commit again. The update lives in doc-values generation 1.
  2. Open r1 on commit 1, then r2 = openIfChanged(r1, commit 0). r2 shares r1's segment and its doc-values cache, but does not use generation 1.
  3. Close r1 while one input close() throws an IOException. Lucene closes the generation-1 producer, but the throw skips removing it from the cache, and its reference count is put back to 1.
  4. Reopen: r3 = openIfChanged(r2, commit 1). You expect doc 0 to read n = 10, as a fresh DirectoryReader.open(commit 1) does.
  5. Instead r3 is handed the cached, closed producer: getNumericDocValues("n") throws AlreadyClosedException: Already closed., and keeps throwing until every reader of the segment is closed.

Who is affected

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.

Terms

  • 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), n is 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 every SegmentReader of that core (SegmentReader.java:153, this.segDocValues = sr.segDocValues;). It maps each generation to a RefCount<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.

The bug

// 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 check
  1. r1.close() reaches SegmentReader.doClose (SegmentReader.java:214-225), which decRefs generation 1 once. The count hits 0, release() runs, and Lucene90DocValuesProducer.close (:510-512, IOUtils.close(data, ...)) throws from the data input, which the repro's wrapper has already closed (IOUtils.close still tries the other inputs).
  2. The removal at :57 is skipped and RefCount.decRef restores the count to 1. IndexReader.decRef set closed = true before doClose (IndexReader.java:222-227), so r1 can never retry: the count has no owner.
  3. 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 stock MMapDirectory input close propagates no exception (MemorySegmentIndexInput.java:707-730 retries until the arena closes); FSDirectory.open returns MMapDirectory on 64-bit JREs and NIOFSDirectory otherwise (FSDirectory.java:155-161). NIOFSDirectory (FileChannel.close, NIOFSDirectory.java:122-126) or a custom or filter Directory can 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 fresh DirectoryReader.open of the same commit reads correct values. Before 6.6.0 the failed close() itself returned normally, so the first sign was the later read failure.
  • Not claimed. Wrong results, corruption, and the NRT (IndexWriter reader 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).

Minimal repro

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.

TestReopenAfterFailedDocValuesClose.java (one test, lucene/core)86 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.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);
  }
}
Command
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) reads n = 10 for doc 0, as a fresh open of commit 1 does.
Observed
r3's first getNumericDocValues("n") throws AlreadyClosedException: Already closed.

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

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.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0 and 9.12.3
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

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)

Why it looks unintended

  • openIfChanged(reader, commit) promises to "open and return a new reader"; a fresh open reads n = 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.close only says no other methods should be called on the closed reader (IndexReader.java:345-346), and the repro never touches r1 again.
  • The cache's design. A design comment in the LUCENE-5189 discussion (#6253) plans for SegmentDocValues to 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. RefCount kept the same put-back one level down.
  • Precedents and tests. LUCENE-7775 (#8826: decRef dropped 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 in SegmentDocValues.decRef as bugs. TestDirectoryReaderReopen.testOverDecRefDuringReopen asserts 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.

Suggested fix

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.

Remove the cache entry, then close (two lines moved) (validated by the checks) (recommended)

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.

Keep close first, remove in a finally block (not run)

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

Drop the put-back in RefCount.decRef (not sufficient alone) (not run)

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 note

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.

How it was found

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), and RefCount.decRef restores 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA archive; web2026-11-03two scripted prior-report search runs (54 and 86 hits) and 37 manual GitHub searchesonly the related items above
Open PRs2026-11-03the changed files of all 410 open apache/lucene PRs; diffs of those touching nearby filesnone changes SegmentDocValues.java or RefCount.java
Recheck (GitHub)2026-11-03class, method and symptom queries; PRs updated since 2026-11-02; upstream file historynothing new; upstream main 1c2072f0284 still closes before removing

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
4.5.1 and earlier no APIno doc-values updates, no SegmentDocValues or RefCount (the probe does not compile)
trunk b6eab2b39aa (parent) not affectedthe probe passes: r3 reads 10
trunk d37b2cb90b1 introducedthe probe fails with AlreadyClosedException
4.6.0 first: 4.6.0the probe fails (assertions on and off); the failed close returns normally
4.10.4, 5.5.5, 6.5.1 affectedthe probe fails; the failed close returns normally
6.6.0, 6.6.6, 7.7.3, 8.11.4 affectedthe probe fails; the failed close throws
9.12.3, 10.5.2, 10.6.0 affectedthe JUnit tests fail; this doc's one-test repro fails on 9.12.3 and 10.6.0
main 5adff78bb168 affectedthe one-test repro fails through gradle, assertions on and off; passes with the fix

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedTest-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, LowOwn 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, LowSteelmanned 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); LowRe-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, LowFresh seeds fail on and off; the one-test repro fails on and off and passes with the fix.
this doc reproduced, fix validatedGradle 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).

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 notes1,584 words

Finding record

LFV-1217, snapshot of 2026-11-06

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 related org.apache.lucene.index suites and :lucene:core:check -x test with the fix, 2026-11-03); the 10.6.0 and 9.12.3 release jars (the same test, assertions off). Earlier checks ran on main 5adff78bb168 (the cross-model review on ea57360912ed, whose relevant files are identical, then re-run on 5adff78bb168), and a history probe on releases from 4.6.0 to 10.6.0 and on two source-built 2013 commits.

Lucene and upstream sources

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-86
  • lucene/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-225
  • lucene/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-287
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:215-232, (at 5adff78bb168) :340-346
  • lucene/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)

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

Repro files

About the diagrams

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.

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.