Every Lucene reader counts its users and closes its files only when the last one lets go. Closing a FilterCodecReader skips that count for the reader it wraps.
DirectoryReader and a wrapper share one segment, closing the wrapper closes the segment's files while two users still hold it, and the reader then finds 0 hits. Select the still to open it at full size. Generated with AI, checked against the code.- You open a
DirectoryReaderon 10 documents in one segment (default codec,MMapDirectory);id:3has 1 hit. To copy the segment into another index you wrap it in aFilterCodecReaderforaddIndexes, first taking a reference for the wrapper withincRef(): the segment's count is 2. - After
addIndexes(10 documents copied) you close the wrapper. You expect it to give back its reference, count 1, and theDirectoryReaderto keep working: a reader "will not really be closed until decRef has been called for all outstanding references". - Instead
FilterCodecReader.doClose()calls the segment's own close hook,in.doClose(), notin.close(). The segment's files close while its count stays 2, so it still looks open. - The
DirectoryReadernow finds 0 hits forid:3, with no exception, and stored fields throwAlreadyClosedException. Releasing the leftover reference and closing the reader releases the segment a second time:close()throws anAssertionError(aNullPointerExceptionwith assertions off). - Without
incRef()it is the same, for example after re-sorting an index withSortingCodecReader.wrapand closing the wrappers.FilterLeafReadercallsin.close(): clean with the extra reference, a loudAlreadyClosedExceptionwithout it.
Applications that close a FilterCodecReader or SortingCodecReader while the wrapped segment is still used by an open or near-real-time reader, for example after copying or re-sorting an index with addIndexes. Lucene's production code never closes these wrappers, so normal indexing and search are unaffected. No scale is needed; the default codec is affected.
Mechanism, terms and numbers645 words
FilterCodecReader.doClose() calls in.doClose(), the wrapped reader's internal close hook, instead of in.close(), which releases one reference. So closing the wrapper closes the wrapped segment's files even while another reader, typically the DirectoryReader the segment came from, still holds it. With the default codec and MMapDirectory, that reader then silently finds 0 hits for a term that matched 1 document, its stored fields throw AlreadyClosedException, and its own close() later releases the segment a second time and, if the segment has doc values, throws. FilterLeafReader calls in.close() and is correct in the same sequence; FilterDirectoryReader also forwards to in.close() (the LUCENE-6696 fix). The call came in 6.4.2 (LUCENE-7676, 2017); every release from 6.4.2 to 10.6.0 (10.6.0 by reading) and main is affected. Lucene's production code never closes these wrappers, so only applications that close one while its segment is still in use are hit.
- Reference count. Every
IndexReadercounts its users:incRef()adds one,close()anddecRef()remove one, and the reader closes its files only when the count reaches 0.doClose()is the hookdecRef()runs at that moment (IndexReader.java:215-232, "Implements close." at:358-359). - Segment reader (leaf). A
DirectoryReaderholds oneSegmentReaderper segment; each is aCodecReader. FilterCodecReader. A public abstract wrapper around aCodecReader, mainly used to feedIndexWriter.addIndexes(CodecReader...)and merges.SortingCodecReaderis a subclass.
FilterCodecReader.doClose()isin.doClose();(FilterCodecReader.java:123-126). It compiles only because the class is inIndexReader's package.- Closing the wrapper takes the wrapper's own count to 0 and runs this hook, which runs
SegmentReader.doClose()on the wrapped segment:core.decRef()andsegDocValues.decRef(...)(SegmentReader.java:213-225). The segment's own count is not touched, so it stays above 0, itsclosedflag stays false andensureOpen()keeps passing (IndexReader.java:238-248: it checks the count and the never-setclosedByChildflag, notclosed). - The shared core closes postings, stored fields, points, vectors and norms (
SegmentCoreReaders.java:167-180). The terms reader clears its field map on close (Lucene103BlockTreeTermsReader.java:272-280) and the points reader its readers map (Lucene90PointsReader.java:153-157), so with the default codec term and point queries see an empty segment, with no exception (randomized test codecs and directories can throw instead). Stored fields, doc values and vectors throwAlreadyClosedException. In the example the segment reader is the core's only user; readers of one segment may share the core and the doc-values producers (SegmentReader.java:42-43), and then the files close at a later release, possibly while another reader still uses them (read, not run). - When the owner's release takes the segment's count to 0 (
StandardDirectoryReader.java:566),SegmentReader.doClose()runs a second time.SegmentDocValues.decReffinds the producer already gone:assert dvp != null : "gen=" + gen, or aNullPointerExceptionwithout assertions (SegmentDocValues.java:83-84). A segment without doc values closes silently. While another holder keeps a reference the owner's close can be clean: in the near-real-time probe it was, because the IndexWriter's reader pool still held the segment reader. FilterLeafReader.doClose()andFilterDirectoryReader.doClose()callin.close()(FilterLeafReader.java:416-419,FilterDirectoryReader.java:162-164). A bytecode sweep over the core, test-framework and module jars (all but benchmark, demo, luke and distribution) finds no other call of another reader'sdoClose().
Default codec, MMapDirectory, 10 documents in one segment, id:3 has 1 hit. With an extra reference taken for the wrapper, the segment's count is 2 before and after the wrapper's close; after the leftover reference is released, count(id:3) is 0, stored fields throw AlreadyClosedException: this FieldsReader is closed, and DirectoryReader.close() throws AssertionError: gen=1 (or NullPointerException ... "dvp" is null), on main and 10.5.2, with assertions on and off. Re-sorting with SortingCodecReader.wrap and addIndexes, then closing the wrappers without an extra reference: the copy has all 10 documents, the still-open source reader finds 0 hits for id:3, and its close() throws gen=-1.
Exposure and likelihood379 words
- Who is hit. Code that closes a
FilterCodecReader(its own subclass, or the wrapperSortingCodecReader.wrapreturns: aSortingCodecReader, or an anonymousFilterCodecReaderwhen the reader is already sorted) whose wrapped reader is a leaf of an openDirectoryReaderor a near-real-time reader. The natural trigger is copying, filtering, re-sorting or reordering an index throughaddIndexes(CodecReader...)and then closing the wrappers, with or without an extra reference:addIndexesdoes not close the readers it is given ("The provided IndexReaders are not closed"), andFilterCodecReaderdoes not document whether closing it releases the wrapped reader. - Who is not. Lucene's production code (no harmful call site found): merges release the pooled segment reader, not the merge wrapper;
SoftDeletesDirectoryReaderWrappercloses only its wrappedDirectoryReader; the misc splitters and the BP reorderer's documented usage never close their wrappers. Two downstream wrappers that were read (Solr's index splitter, Elasticsearch's lazy soft-deletes reader) are not closed directly. No affected downstream is known. The test framework closes such a wrapper in two norms-format tests, harmlessly (nothing uses the reader afterwards). - What the damage looks like. On the damaged segment, with the default codec and
MMapDirectory, term and point queries silently match nothing (randomized test codecs and directories can throw instead); stored fields, doc values and kNN throw; other segments answer correctly. The owner'sclose()throws when it takes the segment's count to 0 and the segment has doc values. With a near-real-time reader (no deletes; with deletes not run) the damage carries into readers reopened from the same IndexWriter, and the next merge of that segment is a tragic event that closes the writer and loses uncommitted documents (5 of 15 in a probe); the committed index stays intact andCheckIndexis clean. - Settings. Opt-in usage pattern of an expert API; otherwise defaults: default
IndexWriterConfig, default codec, assertions on or off. 10 documents are enough. The root cause does not depend on the directory; the runs on record usedMMapDirectoryand randomized test directories on JDK 25 / Linux aarch64. - How likely. Rare: it needs code that closes such a wrapper and then keeps using the wrapped reader's owner. A one-off migration often closes the source reader right after
addIndexes, which then only shows as a throwing close. No report since 2017.
The test shows that closing a FilterCodecReader over an incRef'd segment of an open DirectoryReader runs the segment's doClose() directly, so the still-open DirectoryReader silently loses the segment's terms.
/*
* 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestFilterCodecReaderCloseKeepsReferencedReader extends LuceneTestCase {
public void testClosingWrapperKeepsReferencedSegmentSearchable() throws IOException {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig config = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
try (IndexWriter w = new IndexWriter(dir, config)) {
Document doc = new Document();
doc.add(new StringField("id", "1", Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
CodecReader segment = (CodecReader) reader.leaves().get(0).reader();
segment.incRef();
new FilterCodecReader(segment) {
@Override
public CacheHelper getCoreCacheHelper() {
return null;
}
@Override
public CacheHelper getReaderCacheHelper() {
return null;
}
}.close();
// expected 1: the open DirectoryReader still references the segment; actual 0
assertEquals(
"id:1 hits in the open DirectoryReader after closing a FilterCodecReader",
1,
new IndexSearcher(reader).count(new TermQuery(new Term("id", "1"))));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-235/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.index.TestFilterCodecReaderCloseKeepsReferencedReader
- Expected vs actual
- Expected
count(id:1)= 1 (the DirectoryReader still holds a reference). Actual: 0, with no exception.
More files, environment, full output43 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: id:1 hits in the open DirectoryReader after closing a FilterCodecReader expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.index.TestFilterCodecReaderCloseKeepsReferencedReader.testClosingWrapperKeepsReferencedSegmentSearchable(TestFilterCodecReaderCloseKeepsReferencedReader.java:55)- The
IndexReaderjavadoc: a reader "will not really be closed until decRef has been called for all outstanding references". FilterLeafReaderandFilterDirectoryReadercallin.close(); LUCENE-6696 (2015) fixed this samein.doClose()mistake inFilterDirectoryReader.- It came in 6.4.2 with a test that only checks that every method is overridden; before, closing the wrapper never touched the wrapped reader.
The full argument348 words
- Contracts.
IndexReader#incRef(IndexReader.java:164-170, repeated fortryIncRefat:188-192): "RefCounts are used to determine when a reader can be closed safely ... the IndexReader will not really be closed until decRef has been called for all outstanding references."decRef(:207-209): "If the refCount drops to 0, then this reader is closed."doClose(:358-359): "Implements close."FilterLeafReader(FilterLeafReader.java:328): "Note that base reader is closed if this FilterLeafReader is closed", implemented asin.close().addIndexes(CodecReader...)(IndexWriter.java:3257): "The provided IndexReaders are not closed": IndexWriter leaves their lifecycle to the caller.FilterCodecReaderitself documents no ownership: its constructor only storesinand takes no reference (FilterCodecReader.java:54-61). - Sibling code.
FilterLeafReaderandFilterDirectoryReadercallin.close();MultiReadercallsclose()ordecRef();DirectoryReaderreleases its leaves withdecRef(). A bytecode sweep over the core, test-framework and module jars (all but benchmark, demo, luke and distribution) finds no other call of another reader'sdoClose(). - Tests. No upstream test asserts the current behaviour (
TestFilterCodecReaderchecks only that every method is overridden, andgetDelegate). Two norms-format tests close a test-frameworkMergingCodecReaderleaf instead of itsDirectoryReaderand rely on that releasing the segment: they fail with an emptydoClose()and pass within.close(). That is compatibility evidence against option B, not a documented ownership contract. - History.
in.doClose()came with LUCENE-7676 (#8727,ae68e6cebc3, 2017-02-08, @cpoerschke), "Fixed FilterCodecReader to override more super-class methods", together with the reflective test that requires an override of everyCodecReadermethod. The issue body is empty; @rmuir wrote "looks good" and @jpountz "+1"; nobody discussed close behaviour. Before it, closing aFilterCodecReaderdid not touch the wrapped reader (5.0.0 to 6.4.1, where the inheritedCodecReader.doClose()closed only the wrapper's own thread-local state; executed on 5.5.5 and 6.4.1). - Precedent. LUCENE-6696 (#7754, 2015, @jpountz, fixed in 5.3.0): the same
in.doClose()inFilterDirectoryReader, fixed within.close(): "This is wrong because if you call close() on both the underlying reader and a wrapper around it, then doClose() will have been called several times, which will break ref counting."
The devil's-advocate check288 words
The strongest "works as designed" case (built by the cross-model refute reviewer, the intended-behaviour reviewer and the devil's advocate; the cross-model reviews rated it weak, and the devil's advocate found that none of its points holds up): FilterCodecReader is a merge and addIndexes view that Lucene's production code never closes; SortingCodecReader "should only be used for merging"; its constructor takes no reference and no javadoc says whether the wrapper owns its delegate (the strongest point), so closing a wrapper around someone else's segment without taking a reference could be called a caller error; and the line is old and two committers approved it.
Why it fails: "never closed by Lucene" limits reach, not validity, since close() is public and addIndexes does not close the readers it is given; "only for merging" is about cost. No ownership model makes in.doClose() right: owning one reference means in.close(), owning none means an empty doClose() (as before 6.4.2, the wrapped reader is left alone), and even exclusive ownership would leave the wrapped reader looking open. Even the "caller error" case (no extra reference) gives a silent 0 hits where FilterLeafReader throws AlreadyClosedException. The 2017 review discussed only the test, so the line's age is no evidence of acceptance. What the case gets right: ownership is undocumented, so closing a wrapper without a reference can reasonably be called misuse; under that reading option A turns the silent 0 hits into a loud AlreadyClosedException rather than making the call work, and A releases only one reference even when several wrappers each took one. That bears on the fix and the framing; with a reference taken and released, the result still breaks the documented incRef/decRef contract. Verdict: not intended, a defect.
Recommended Release one reference, like FilterLeafReader: in.close()
Replace in.doClose() with in.close() in FilterCodecReader.doClose(), and add a javadoc line modelled on FilterLeafReader's: the wrapped reader is closed when the wrapper is. Both repro tests (assertions on and off), the whole core module (8,871 tests), the codecs and misc modules and the static checks (assertions on) pass. An empty doClose() also fixes the repro but fails two norms-format tests.
@Override
protected void doClose() throws IOException {
- in.doClose();
+ in.close();
}All options and trade-offs3 options ยท 638 words
Three code options were built and run in a scratch worktree at ea57360912ed. How each behaves when a caller closes a wrapper: with an extra reference taken (the test), A and C are correct and B leaks that reference; without one (the re-sort flow), B is correct and A and C fail loudly with AlreadyClosedException instead of today's silent 0 hits; with two wrappers over one segment, each with its own reference, only C releases both (A releases one, as FilterLeafReader does today).
doClose() calls in.close(); the constructor javadoc gains "Note that the underlying reader is closed if this FilterCodecReader is closed: closing this reader releases one reference to it". +4/-1 lines; ./gradlew tidy leaves it unchanged.
@Override
protected void doClose() throws IOException {
- in.doClose();
+ in.close();
}Trade-offs
- Matches
FilterLeafReader,FilterDirectoryReaderand the LUCENE-6696 fix. - Callers who close a wrapper without taking a reference and keep using the owner now get
AlreadyClosedExceptioninstead of a silent 0 hits and a throwing close: loud, but a behaviour change (the re-sort probe). - Two wrappers over one segment, each with its own reference, release only one between them, because
IndexReader.close()is idempotent per instance;FilterLeafReaderhas the same property today. - The same idempotency means that after the wrapper's
in.close()the wrapped reader's ownclose()does nothing, so an owner that releases it withclose()rather thandecRef()leaks one reference.DirectoryReaderand the IndexWriter's reader pool release leaves withdecRef(), so the repro and the validated suites are unaffected;FilterLeafReaderandFilterDirectoryReaderbehave the same today (executed withFilterDirectoryReaderby the fact check).
What the checks validated
- Both repro tests: 6 of 6 pass with
-eaand-da; 15 of 15 with-Ptests.iters=5. - The two repro classes plus 18 related core suites (
TestFilterCodecReader,TestIndexReaderClose,TestSoftDeletesDirectoryReaderWrapper,TestSortingCodecReader,TestIndexSorting,TestAddIndexes,TestIndexWriter,TestIndexWriterReader, ...): 372 tests, 0 failures. - The whole core module: 749 classes, 8,871 tests, 0 failures, 322 skipped. The whole codecs module: 693 tests, 0 failures. The whole misc module: 315 tests, 0 failures. These module runs, the 18 suites and the static checks ran with assertions on (seed DEADBEEF); with assertions off, the repro tests and the standalone program.
./gradlew :lucene:core:check -x test(formatting, ecj lint, forbidden APIs): BUILD SUCCESSFUL.- The standalone program on the fixed jar: 1 hit, clean close, with
-eaand-da; the security probe: 0 violations.
Closing a wrapper never affects the wrapped reader, as in 5.0.0 to 6.4.1 (where the inherited CodecReader.doClose() closed only the wrapper's own thread-local state), and like the view SlowCodecReaderWrapper.wrap builds for a reader that is not a CodecReader (a CodecReader is returned unchanged).
Trade-offs
- The try-with-resources idiom around
addIndexesjust works. - A caller who takes a reference for the wrapper, as
FilterLeafReaderneeds, silently leaks it, and the segment is never closed. - Breaks
TestLucene90NormsFormatMergeInstance.testIndependentIteratorsandtestIndependentSparseIterators("MockDirectoryWrapper: cannot close: there are still 7 open files"), which close aMergingCodecReaderleaf instead of itsDirectoryReader; they would have to change (compatibility pressure, not an ownership contract).
What the checks validated
Both repro tests and the 18 related suites pass with -ea and -da (372 tests, 0 failures); the whole core module: 8,871 tests, 2 failures (the two norms tests).
Releases exactly one reference per wrapper close.
Trade-offs
Two wrappers over one segment are correct too; it departs from the in.close() convention of the sibling classes, and behaves like in.close() when no reference was taken.
What the checks validated
Both repro tests and the 18 related suites pass with -ea and -da (372 tests, 0 failures); the whole module was not run.
Once the wrapper is closed, the segment's term and point readers are empty but still answer, so an application that enforces document-level restrictions as MUST_NOT clauses on terms or points stops excluding that segment's documents (executed: 10 hits instead of 8), and FILTER clauses on them match nothing.
Code reading during a review of how Lucene's filter readers delegate. A bytecode scanner had listed FilterCodecReader.doClose() as a plain forward to the wrapped reader; reading it next to FilterLeafReader showed that forwarding this hook skips the reference count. Not a formal-methods find; a scanner check added afterwards finds no other such call.
The method in full164 words
Method: code reading (the hunt's "non-formal" tier), a hunt unit (delegation in Lucene's filter readers, 2026-10-26).
- The finder (an AI tool) wrote a bytecode scanner (JDK class-file API, over the core, test-framework and module jars, all but benchmark, demo, luke and distribution) that classifies every method of every wrapper class as a forward, a
supercall or custom code, and runs checks for missed overrides, partial views and cache-helper misuse. It listedFilterCodecReader#doCloseas a plain forward. - Reading the code next to
FilterLeafReader's showed that forwarding this particular hook skips the delegate's reference count. A JUnit on the stock jars confirmed it. - Check F8 (any
IndexReader#doClose()called on an object other thanthis/super) was then added to the scanner; it finds only this call. No formal or bounded-exhaustive tool produced the first signal. - Then an independent reviewer from another model family (OpenAI GPT-5.6) tried to refute it by running its own tests, and the hunt's checks followed (see the record).
No prior report: 223 read-only queries on 2026-10-26 over GitHub issues and PRs, commits, four mailing lists, JIRA and the web, plus the changed files of all 413 open Lucene PRs. The closest item is LUCENE-6696 (#7754), the same mistake fixed in FilterDirectoryReader in 2015.
Searches6 searches
Novelty new: nobody has reported FilterCodecReader#doClose calling in.doClose() or its symptoms. Main, branch_10x and branch_10_6 still have the call; the four open PRs that touch FilterCodecReader.java (#16571, #16847, #12295, #13342) leave doClose unchanged. Search gaps: keyword search cannot rule out a report that describes only the symptoms without naming the class or the error messages; one tool run hit GitHub's search rate limit and the remaining phrasings ran through GraphQL search (the same index); private security reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | REST and 40 GraphQL queries: FilterCodecReader with close, doClose, refCount, incRef, decRef, AlreadyClosedException; in.doClose(); SortingCodecReader close; SlowCodecReaderWrapper; LUCENE-7676; LUCENE-6696; the error strings | no report; related threads read (58 in total) |
| Downstream and all repositories | 2026-10-26 | 17 GraphQL queries on Elasticsearch, OpenSearch, Solr, lucene-solr, crate and k-NN; 6 over all repositories; 10 code searches for FilterCodecReader subclasses that override doClose | no report; no downstream FilterCodecReader overrides doClose |
| Open apache/lucene PRs | 2026-10-26 | changed-file lists of all 413 open PRs | #16571, #16847, #12295, #13342 touch the file; none changes doClose |
| Mailing lists | 2026-10-26 | dev@, java-user@, issues@ and commits@: 16 direct queries plus the tool's list queries | no report; FilterCodecReader threads are about migration, splitting and cache helpers |
| JIRA archive (LUCENE, SOLR) | 2026-10-26 | 7 queries plus the comments of 16 issues | LUCENE-6696 (precedent, FilterDirectoryReader) and LUCENE-7676 (introduced the call); neither reports this bug |
| Web | 2026-10-26 | general web searches | nothing relevant |
Introduced in 6.4.2 by LUCENE-7676 (ae68e6cebc3, 2017), which added the in.doClose() override to satisfy an "override every method" test. All 75 release tags from 6.4.2 to 10.5.2 contain the call; the finder's JUnit fails on all 9 releases run in that range and on main; 6.4.1 and 5.5.5 pass.
Versions tested8 rows
Introduced by ae68e6cebc3 (master, 2017-02-08, LUCENE-7676 / #8727, "Fixed FilterCodecReader to override more super-class methods. Also added TestFilterCodecReader class.", @cpoerschke), backported as 05e0250ee02 (branch_6x, first released in 6.5.0) and e0653d96c61 (branch_6_4, committed 2017-02-22, first released in 6.4.2). Before it, FilterCodecReader inherited CodecReader#doClose, which never touches the wrapped reader (in 5.x to 6.4.1 it closed only the wrapper's own thread-local state). The line is unchanged since then; branch_10x and branch_10_6 still have it, so 10.6.0 (released 2026-10-29) is very likely affected (read, not executed).
Swapping only FilterCodecReader.java between 6.4.1 and 6.4.2 moves the failure in both directions; the 2017 commit itself was not rebuilt. On 6.4.2 and 6.5.0 without assertions only the 0-hit test fails, because SegmentDocValues.decRef swallowed the second-release exception until LUCENE-7775 (6.6.0).
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed | affected | this doc's test: 2 of 3 fail with -ea and -da (0 hits; gen=-1 or NPE); the standalone program prints 0 hits and a throwing close |
| 10.5.2 | affected | the finder's JUnit fails 2 of 3 with -ea and -da; the standalone program and the re-sort probe print 0 hits and a throwing close |
| 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.12.3 | affected | the finder's JUnit (version-adapted, same logic) fails 2 of 3 with -ea and -da |
| 10.0.0 | affected | fails 2 of 3 with -ea (-da not run) |
| 6.4.2, 6.5.0 | affected | fails 2 of 3 with -ea; 1 of 3 (0 hits) with -da |
| 6.4.1 and 6.4.2 with swapped FilterCodecReader.java | file swap | 6.4.1 with the 6.4.2 file fails 2 of 3; 6.4.2 with the 6.4.1 file passes 3 of 3 |
| 5.5.5, 6.4.1 | not affected | pass 3 of 3 with -ea and -da (no doClose override) |
fix in.close() on ea57360912ed | not affected | both tests pass; whole core module 8,871 tests, 0 failures |
An independent reviewer from another model family, an independent reproducer, an intended-behaviour reviewer, a devil's advocate and an adjudication all rated it real, the adjudication with high confidence. This doc's own runs re-checked the test and the fix, and found that the empty-doClose() alternative fails two upstream tests.
Checks and verdicts708 words
Each check ran with its own lens; all executed checks used the stock main jars at ea57360912ed, with assertions on and off.
| Check | Verdict | What it found |
|---|---|---|
| cross-model refute review | real (high), intended argument weak | An independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own tests: an open reader gets 0 hits instead of 1, and the wrapped reader's close hook runs at reference count 2; direct-close controls pass; with in.close() its tests and three nearby upstream suites pass. |
| Independent reproducer | real | Its own reference-neutral JUnit fails 3 of 4 on 5 seeds and on 10.5.2 and 9.12.3; its standalone program shows the silent 0 hits and the throwing close; in.close() and an empty doClose() each fix its tests. |
| Intended-behaviour review | real (high), intended argument weak | Reading only (OpenAI GPT-5.6); a separate run re-ran the finder's JUnit on main and 10.5.2 (2 of 3 fail) and the fix (3 of 3 pass). |
| Devil's advocate | real (high) | Built the strongest "works as designed" case and showed why it fails; its no-extra-reference probe gets a silent 0 where FilterLeafReader throws. |
| adjudication | confirmed (high), silent wrong results | Re-ran the independent reproduction's JUnit with a new seed and the standalone program with -ea and -da, and the in.close() fix (4 of 4 pass). |
| security screen | none (earlier "out of scope of SECURITY.md", retired 2026-10-27) | An executed probe showed MUST_NOT deny rules on terms and points failing open in the damaged segment, also in reopened NRT readers; trigger application-only. |
| this doc's runs | fix A validated; option B fails 2 upstream tests | A deterministic rewrite of the test, the re-sort and two-wrapper probes, and the three fix options through gradle in a scratch worktree. |
| Independent fact check | 1 text error and 9 minor findings, all fixed | Re-ran this doc's test and the finder's on a clean copy of ea57360912ed (assertions on and off, a new random seed), the three fix options, the standalone program on main and the 10.5.2 jar (both assertion modes) and its own controls, and checked every cited line, commit, count and quote. Its one error: the summary tagged FilterDirectoryReader as executed in the same sequence; only FilterLeafReader was, and its own FilterDirectoryReader run showed a further trade-off of in.close() (the owner's later close() does nothing). Both are in this doc now, with the 9 minor wordings. |
| Cross-model fact check (OpenAI GPT-5.6, reading only) | real; 9 of its 10 errors valid, all fixed | Read-only review against the code and the run records: the bug is real as described. It found evidence labels that overstated what ran (assertion modes per run, a 5-seed claim with no record, the 10.5.2 runs, "every release" tagged executed), wording overclaims ("silently" without the codec, "any directory", "Lucene never closes", "ids leak") and missing caveats (shared cores, NRT with deletes, the one-segment assumption, undocumented ownership); all taken. One error, about the 8.11.4 javap check, was itself wrong (the jar exists and shows the call). |
| Diagram check | 2 minor label errors, fixed and re-rendered | Every value, line reference and order of events matched the code and the probe. The summary card's "Affected" line now says 10.0.0 ran only with assertions on, and the control row says the FilterLeafReader view went through the same steps without addIndexes (it is not a CodecReader); the security strip then gave both rules' numbers. On 2026-10-27 the security strip was removed (boolean security rule) and they were re-rendered again. |
| Correction to the registry row | 5 wordings out of date | The registry row (fact strip and registry card) still has wordings the checks corrected here: impact_note and notes say the owner's close throws when the segment has "doc-values updates" (plain doc values are enough: gen=-1); impact_note says "Lucene's own code" never closes these wrappers (its production code; the test framework closes one in two norms tests); verification says the finder's test fails "on 5 of 5 seeds" (no run record); found_by says the sweep covered "all core and module jars" (all but benchmark, demo, luke and distribution); notes says the intended case was "all rated weak" (the cross-model reviews did; the intended-behaviour check found none of its points holds up). |
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 461 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,306 words
- Registry title
- Closing a FilterCodecReader runs the wrapped reader's close hook directly (in.doClose() instead of in.close()), so a DirectoryReader that still uses that segment silently finds 0 hits, and its own close later releases the segment a second time and throws
- Area
- index / FilterCodecReader reader lifecycle (IndexReader reference counting)
- Priority
- Medium, rank 86: Since 6.4.2 closing a FilterCodecReader runs the wrapped segment's close hook directly, so a DirectoryReader or NRT reader that still uses that segment silently finds 0 hits there (MUST_NOT deny rules fail open) until a later merge of it closes the writer, a core lifecycle break through public API, but it needs application code that closes such a wrapper over a delegate still in use, which Lucene and the known downstream wrappers never do.
- Impact class
- silent wrong results
- Impact note
- Applications that close a FilterCodecReader (a custom filter, or the wrapper SortingCodecReader.wrap returns) whose delegate is still referenced elsewhere, typically a leaf of an open or near-real-time DirectoryReader wrapped for IndexWriter.addIndexes(CodecReader...) and closed with try-with-resources, are left with a still-open reader whose term and point queries silently match nothing in that segment (default codec, MMapDirectory, -ea and -da), whose stored fields and doc values throw AlreadyClosedException, and whose own close() later throws when the segment has doc-values updates (AssertionError gen=1 with -ea, NullPointerException with -da); with a near-real-time reader the damage carries into later readers of the same IndexWriter until the next merge of that segment fails as a tragic event that closes the writer and loses uncommitted documents (the committed index stays intact); Lucene's own code and the downstream wrappers that were read never close these wrappers, so ordinary indexing and search are not affected; no scale is needed; every release from 6.4.2 to 10.6.0 is affected (10.6.0 by reading).
- Summary
- You wrap a segment of an open DirectoryReader in a FilterCodecReader (for example to pass it to IndexWriter.addIndexes), take the usual extra reference with incRef(), and close the wrapper, expecting the DirectoryReader to keep working, because the IndexReader javadoc says a reader 'will not really be closed until decRef has been called for all outstanding references' and FilterLeafReader behaves exactly that way. Instead FilterCodecReader.doClose() calls the wrapped reader's internal close hook (in.doClose()) rather than releasing a reference (in.close()), so the segment's files are closed while the DirectoryReader still uses it: a TermQuery that matched 1 document now finds 0 with no error, reading stored fields throws AlreadyClosedException, and closing the DirectoryReader later releases the same resources a second time and throws.
- Introduced
- ae68e6cebc363e51cd68aee83830b5cb427b4799 (master, 2017-02-08, 'LUCENE-7676: Fixed FilterCodecReader to override more super-class methods', by cpoerschke; JIRA patch era, migrated as apache/lucene#8727, no pull request; on the issue rmuir wrote 'looks good' and jpountz '+1', both about the new test only), backported as 05e0250ee02816c1d0b8387dbaa777747bfeb6f0 (branch_6x, 2017-02-08, first released in 6.5.0) and e0653d96c61421c4b11b753c1afe6aac7e9bb087 (branch_6_4, 2017-02-22, first released in 6.4.2). It added 'doClose() { in.doClose(); }' together with TestFilterCodecReader#testDeclaredMethodsOverridden, a reflective test that requires FilterCodecReader to override every CodecReader method; before it FilterCodecReader inherited CodecReader#doClose, which never touches the delegate [read: git blame of FilterCodecReader.java:123-126 and git log -G doClose / -S 'in.doClose()' on the file return only ae68e6cebc3; exec: swapping only FilterCodecReader.java between 6.4.1 and 6.4.2 moves the failure in both directions, and a no-op doClose on main makes the repro pass with -ea and -da; the 2017 commit itself was not rebuilt] (2017-02-08 (master and branch_6x); 2017-02-22 (branch_6_4 backport))
- First affected version
- 6.4.2 [exec] (6.4.1 and 5.5.5 pass [exec]; FilterCodecReader exists since 5.0.0 (LUCENE-6165) and had no doClose override before 6.4.2 [read]; the first affected release on the main line is 7.0.0 [exec])
- Affected versions
- every release from 6.4.2 to 10.5.2 (the last before 10.6.0) and main ea57360912ed [exec: the finder's JUnit, in version-adapted sources with identical logic, fails on 6.4.2, 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.0.0, 10.5.2 and main with -ea, and with -da on all of them except 10.0.0 (not run); on 6.4.2 and 6.5.0 with -da only the 0-hit test fails, because SegmentDocValues.decRef swallowed the second-release exception until LUCENE-7775 (6.6.0); read: all 75 release tags from 6.4.2 to 10.5.2 in the history clone contain in.doClose(), and javap of the SHA-1-checked 8.8.2 to 8.11.4 jars shows the same call]; not affected: 5.0.0 to 6.4.1 (no override) [exec: 5.5.5, 6.4.1] and every release before 5.0.0 (no FilterCodecReader); branch_10x 1309534296e3 and branch_10_6 2763db11b0a7 still contain the call [read through the upstream contents API, 2026-10-26], so 10.6.0 (released 2026-10-29) is very likely affected (not executed); not fixed in any release or on main
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, plus the finder's scripted prior-report search; the prior-report record verdict new: 223 logged read-only queries, through a scripted prior-report search over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE and SOLR JIRA archive and the web, and manual ones: 63 GitHub GraphQL searches over apache/lucene, Elasticsearch, OpenSearch, Solr, lucene-solr, k-NN, crate and all repositories, 16 direct list queries, 7 JIRA queries, 10 code searches and the changed-file lists of all 413 open apache/lucene PRs; 58 issue and PR threads read, 21 related hits recorded, all judged not the same bug; no item by anyone); not fixed upstream: main, branch_10x and branch_10_6 still call in.doClose(), and the four open PRs that touch FilterCodecReader.java (#16571, #16847, #12295, #13342) leave doClose unchanged; closest items, not reports of this bug: LUCENE-6696 / #7754 (2015, jpountz, fixed in 5.3.0), the same in.doClose()-instead-of-in.close() mistake in FilterDirectoryReader, two years before FilterCodecReader gained its override, and LUCENE-7676 / #8727 (2017), which introduced the call without discussing close semantics
- Lucene commit tested
- main
ea57360912ed(gradle in a scratch worktree, and the repro harness), with assertions on and off; the finder's JUnit on the release jars 6.4.2, 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.0.0 and 10.5.2 (all fail) and 5.5.5 and 6.4.1 (pass); the standalone program on main and on the 10.5.2 jar; the recommended fix onea57360912ed(repro with assertions on and off; the core, codecs and misc modules and:lucene:core:check -x testwith assertions on).
- Finding record
lucene/core/src/java/org/apache/lucene/index/FilterCodecReader.java:123-126 (doClose calls in.doClose())lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-170 (incRef javadoc: 'the IndexReader will not really be closed until decRef has been called for all outstanding references'), :207-226 (decRef runs doClose only when the refCount reaches 0)lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:328 and :416-419 ('base reader is closed if this FilterLeafReader is closed'; doClose calls in.close()); FilterDirectoryReader.java:162-164 (in.close()); SegmentReader.java:213-224 (doClose: core.decRef, SegmentDocValues.decRef); SegmentDocValues.java:78-86 (the gen assertion and the dvp NPE)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FilterCodecReader.java:123-126; IndexReader.java:164-170, 215-232, 238-248, 351-359; SegmentReader.java:213-225; SegmentCoreReaders.java:167-180; SegmentDocValues.java:78-86; StandardDirectoryReader.java:566; FilterLeafReader.java:328, 416-419; FilterDirectoryReader.java:162-164; SegmentReader.java:42-43Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsReader.java:272-280; codecs/lucene90/Lucene90PointsReader.java:153-157
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3257 (addIndexes: 'The provided IndexReaders are not closed'), 5394-5419 (closeMergeReaders releases mr.reader); ReadersAndUpdates.java:238-245; SortingCodecReader.java:458-481; FilterCodecReader.java:54-61; FilterDirectoryReader.java:162-164
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-170, 188-192, 207-209, 358-359; FilterLeafReader.java:328, 416-419; FilterDirectoryReader.java:162-164; MultiReader.java:97-112; StandardDirectoryReader.java:566; IndexWriter.java:3257; FilterCodecReader.java:54-61Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseNormsFormatTestCase.java:825, 842, 860, 877
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SlowCodecReaderWrapper.java:48-53; IndexReader.java:351-356
- TestFilterCodecReaderCloseKeepsReferencedReader.java (JUnit test):
repro/LFV-235/src/org/apache/lucene/index/TestFilterCodecReaderCloseKeepsReferencedReader.java(62 lines)
Made in parallel with this doc from one executed example on main ea57360912ed (public API, default codec, FSDirectory/MMapDirectory): every count, hit number and exception on screen comes from that run or its fixed counterpart. The PNG is the final summary card.
assets/LFV-235/bug.png(461 KB)
