- A
DirectoryReaderis a list of segment readers, one per segment.DirectoryReader.open(dir, leafSorter)takes aComparator<LeafReader>that orders them, for example newest segment first for time-based indices, so sorted queries can stop early. - Segment readers are reference counted.
openIfChangedbuilds a new reader that shares the unchanged segments with the old one: it adds one reference to each shared segment reader and opens readers only for new segments. - Closing a reader removes its references, and a segment reader closes (with its files) when its count reaches 0. After a normal refresh a shared segment has refCount 2, one per reader, and closes once both readers are closed.
openIfChanged adds a reference to the shared segment and opens the new one, the leaf sorter throws inside the constructor, nothing is released, and closing the old reader leaves the shared segment at refCount 1. Select the still to open it at full size. Generated with AI, checked against the code.- Commit one document (segment
_0) and openDirectoryReader.open(dir, sorter), wheresorteris aComparator<LeafReader>that orders the segments. One segment needs no comparison, so the open succeeds:_0's reader has refCount 1. - Commit a second document (segment
_1) and callDirectoryReader.openIfChanged(reader). The new reader has two segments, so the sorter runs, and here it throws, as a sorter bug on a segment without the sort field or an I/O error it rethrows would. - You expect only the exception. No new reader was returned, so
_0should be back at refCount 1 and closingreadershould close it. The firstopencleans up after exactly this failure. - Instead
_0's refCount is 2 after the failed call and still 1 afterreader.close(), and_1's new reader is never closed: both segment files,_0.cfsand_1.cfs, stay open. Every retried refresh leaks again.
Applications that refresh a reader opened with a leaf sorter (DirectoryReader.open(Directory, Comparator) or a commit overload, since 8.9.0) through openIfChanged, SearcherManager or ReaderManager, or refresh an NRT reader of a leaf-sorting IndexWriter to an explicit IndexCommit. Opt-in; it takes a sorter that throws during a refresh. Each failure leaks segment readers; results stay correct.
Mechanism, terms and numbers571 words
DirectoryReader.open(Directory, Comparator<LeafReader> leafSorter) (LUCENE-9507, since 8.9.0) orders the segments with an application comparator, and the reader keeps that sorter for its refreshes. A non-NRT openIfChanged (and also an NRT reader's refresh to an explicit IndexCommit) goes through the reopen factory StandardDirectoryReader.open(Directory, SegmentInfos, List, Comparator, ExecutorService). It first takes a reference on every segment reader it shares with the old reader and opens readers for new segments, then constructs the new reader with no try/catch. That constructor runs Arrays.sort with the sorter. If the sorter throws, openIfChanged throws too, no new reader is returned, and nothing releases those references: with two one-document segments the shared segment reader's refCount is 2 instead of 1 after the failed call, 1 instead of 0 after the old reader is closed, and the new segment's reader is never closed. The initial open and the NRT open catch any Throwable and release their readers; the reopen factory is the only open path with no cleanup at all. Results are unaffected and the sorter failure is loud; the leak is silent and repeats on every retry. A try/catch of 7 code lines, mirroring the initial open, fixes it. Every release from 8.9.0 to 10.6.0 and main is affected.
- Leaf sorter: the
Comparator<LeafReader>given toDirectoryReader.open(Directory, Comparator)or a commit overload (LUCENE-9507). It isnullby default. The reader keeps it assubReadersSorterand uses it for every refresh. - Reopen factory:
StandardDirectoryReader.open(Directory, SegmentInfos, List, Comparator, ExecutorService)(StandardDirectoryReader.java:174-184at645d4fe14a20),@lucene.internal, behind everyopenIfChangedof a reader that was not opened from anIndexWriter(also theExecutorServiceandIndexCommitoverloads), and behindopenIfChanged(reader, IndexCommit)of an NRT reader.
openIfChanged(reader)reachesdoOpenNoWriter(StandardDirectoryReader.java:477-494),doOpenFromCommit(:496-505) anddoOpenIfChanged(SegmentInfos, ExecutorService)(:507-511), which calls the reopen factory with the reader's own sorter.- The factory calls
createSegmentReaders(:181). For an unchanged segment, "this reader will be shared between the old and the new one, so we must incRef it":oldReader.incRef()(:317-323), refCount 1 to 2. A new segment getsnew SegmentReader(...)(:292-296), refCount 1.createSegmentReadersreleases these itself only when one of its own steps fails (:218-228,:241-244). - Then
return new StandardDirectoryReader(directory, newReaders, null, infos, leafSorter, false, false);(:182-183) runs with no try/catch. ThroughDirectoryReader's constructor (DirectoryReader.java:539) it reachesBaseCompositeReader, which runsArrays.sort(subReaders, subReadersSorter)(BaseCompositeReader.java:75-77): the application's comparator. - The comparator throws,
openIfChangedthrows, and the arraynewReadersis gone: the caller has no handle on the extra reference or on the new reader. - The initial open wraps the same constructor call: "it must be inside try clause so we close readers in that case",
catch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, readers); throw t; }(:96-105). The NRT open also catchesThrowableand decRefs its readers (:128-165).
| step | _0 refCount, expected | _0 refCount, actual |
|---|---|---|
after DirectoryReader.open(dir, sorter) (one segment) | 1 | 1 |
after the failed openIfChanged (two segments) | 1 | 2 |
after reader.close() | 0 | 1 |
The new segment's reader _1 stays at refCount 1 and is never closed. On Lucene's MockDirectoryWrapper, closing the directory then fails with "cannot close: there are still 2 open files: {_1.cfs=1, _0.cfs=1}". Without a leaf sorter no application code runs in that constructor; its only other throw is the "Too many documents" check (BaseCompositeReader.java:89-98), which needs out-of-limit input and is not claimed.
Exposure and likelihood532 words
- Who is hit. Readers opened with
DirectoryReader.open(Directory, Comparator)(DirectoryReader.java:88-91), itsExecutorServiceoverload (:106-110) or the commit overloads (:189-194,:218-226), refreshed withopenIfChanged,SearcherManager(DirectoryReader, SearcherFactory)(SearcherManager.java:158;refreshIfNeededcallsopenIfChanged,:194-205) orReaderManager(DirectoryReader)(ReaderManager.java:82); a fact-check probe ran all three. Also NRT readers from anIndexWriterwithIndexWriterConfig#setLeafSorterwhen they are refreshed to an explicit commit:openIfChanged(reader, IndexCommit)(DirectoryReader.java:282-301) goes todoOpenFromWriter, which for a non-null commit callsdoOpenFromCommitand so the same reopen factory with the writer's leaf sorter (StandardDirectoryReader.java:155,:406-431,:456-460); aSearcherManager(IndexWriter, ..., RefreshCommitSupplier)(SearcherManager.java:114-131) takes this route whenever its supplier returns a commit (:199-200). In the fact-check probe the segment files stay open after the failed refresh, the NRT reader and the writer are closed ("cannot close: there are still 2 open files: {_1.cfs=1, _0.cfs=1}"); a sorter that does not throw leaves nothing open, and the fix covers this route too. There the old segment readers are NRT readers, so the factory opens new readers that share their cores (:298-315): the symptom is the open files, not the old reader's refCount. - Default vs opt-in. The leaf sorter is an expert option,
nullby default. Plain NRT refreshes (openIfChanged(reader)oropenIfChanged(reader, writer)on an NRT reader, or aSearcherManagerwhose refresh commit supplier returnsnull, the default) go through the NRT open, which releases its readers, so this leg does not hit them. - What triggers it. A sorter that throws during a refresh: a comparator bug on a segment that lacks the sort field (for example a timestamp sorter that dereferences
getPointValues("ts"), which isnullfor such a segment,LeafReader.java:510-514), or an I/O error inside a correct sorter:Comparator.comparecannot throw checked exceptions, while per-segment readers such asgetNumericDocValuesdeclareIOException(LeafReader.java:175), so such a sorter that propagates the error must wrap it in an unchecked exception. - How much. Per failed refresh: one extra reference on every segment reader shared with the old reader (every unchanged segment) and one open reader per new segment. Lucene never releases them: after the old reader is closed the shared reader stays at refCount 1 and the segment files stay open. It grows linearly with failed attempts and happens at any index size; one failed call leaks once (the commit lookup retries only after an
IOException,SegmentInfos.java:884). Whether garbage collection later reclaims the file handles was not measured. - What does not happen. No wrong results, no index change, no hang. The refresh fails loudly with the sorter's exception:
SearcherManager.maybeRefresh()rethrows it to its caller and keeps serving the previous searcher (executed), and the old reader keeps working. - Related legs, not part of this report (found by the first finder on
645d4fe14a20): the NRT open withIndexWriterConfig#setLeafSorterkeeps theIndexFileDeleterreferences it took (StandardDirectoryReader.java:148) when the constructor throws, so files of merged-away segments stay until the writer closes; and the initial open's cleanup iterates the array thatArrays.sortwas rearranging, so a late sorter failure in a TimSort merge (32+ segments) leaks some readers ("still 4 open files").
One JUnit test on the public API: a plain ByteBuffersDirectory, NoMergePolicy, two one-document commits (the second from w.close(), which commits by default) and a leaf sorter that always throws. It checks the shared segment reader's refCount right after the failed openIfChanged. Deterministic, single thread, about a second.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.util.Comparator;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestReopenSorterFailureRefCount extends LuceneTestCase {
public void testFailedLeafSortDuringReopenReleasesSharedSegment() throws Exception {
Directory dir = new ByteBuffersDirectory();
IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE));
w.addDocument(new Document());
w.commit();
Comparator<LeafReader> sorter =
(a, b) -> {
throw new IllegalStateException("sorter failure");
};
DirectoryReader reader = DirectoryReader.open(dir, sorter);
LeafReader shared = reader.leaves().get(0).reader();
w.addDocument(new Document());
w.close();
expectThrows(IllegalStateException.class, () -> DirectoryReader.openIfChanged(reader));
// Expected: the failed reopen released its reference (refCount 1); actual: 2 (leaked).
assertEquals(1, shared.getRefCount());
reader.close();
dir.close();
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestReopenSorterFailureRefCount.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestReopenSorterFailureRefCount -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure); the outcome does not depend on the seed
- Expected
- After the failed
openIfChanged, the shared segment reader is back at refCount 1, owned only byreader. - Observed
AssertionError: expected:<1> but was:<2>: the failed call kept its reference; assertions on and off on main, and on 10.6.0.
More files, environment, full output196 words
The smallest input: two segments are the fewest that make the sorter run, and a sorter that always throws needs no field or data. The single assertion is the reference count the failed call should have given back; the same test is the regression test. Assertions on and off give the same failure. All runs were on Linux aarch64 with OpenJDK 25.0.3; the bug is plain control flow, so the platform does not matter.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on
TestReopenSorterFailureRefCount > testFailedLeafSortDuringReopenReleasesSharedSegment FAILED
java.lang.AssertionError: expected:<1> but was:<2>
at org.apache.lucene.index.TestReopenSorterFailureRefCount.testFailedLeafSortDuringReopenReleasesSharedSegment(TestReopenSorterFailureRefCount.java:43)
# assertions off (-Ptests.asserts=false): the same failure
# 10.6.0 release jars, assertions off: java.lang.AssertionError: expected:<1> but was:<2>
# with the fix, assertions on and off
classes=1 tests=1 failures=0 errors=0 skipped=0
# closest suites (TestDirectoryReader, TestDirectoryReaderReopen, TestIndexWriterReader, TestIndexReaderClose, TestReaderClosed, TestSearcherManager), seed DEADBEEF
stock: classes=6 tests=104 failures=0 errors=0 skipped=1
with fix: classes=6 tests=104 failures=0 errors=0 skipped=1
# a second test on MockDirectoryWrapper (sorter dereferencing missing points), after reader.close():
# shared segment reader refCount=1 (expected 0), directory close: MockDirectoryWrapper: cannot close: there are still 2 open files: {_1.cfs=1, _0.cfs=1}- The initial open wraps the same constructor call in
catch (Throwable): "it must be inside try clause so we close readers in that case". openIfChangedmakes the caller own only the reader passed in and a returned one; a failed call returns none.testOverDecRefDuringReopenexpects a failedopenIfChangedto leave no file open.
The full argument225 words
- Ownership contract.
openIfChanged(DirectoryReader.java:236-243): "The provided reader is not closed (you are responsible for doing so); if a new reader is returned you also must eventually close it." - Reference counting.
IndexReader.incRef(IndexReader.java:164-174): "Be sure to always call a corresponding {@link #decRef}, in a finally clause; otherwise the reader may never be closed." - The same class. The initial open (
StandardDirectoryReader.java:96-105), the NRT open (:128-165) andcreateSegmentReaders(:218-228,:241-244) all release their readers on failure (the two related legs under "Who is affected" are remaining gaps of the first two). An independent verifier's control: the initial open with the same throwing sorter over two segments leaves no file open. - Tests.
TestDirectoryReaderReopen.testOverDecRefDuringReopen(:673-735) makesopenIfChangedfail with aFakeIOException, checks that the old reader still works and closes itsMockDirectoryWrapper, which fails on files left open. No test makes a leaf sorter throw. - History. LUCENE-5262 (#6326, 2013, "StandardDirectoryReader should decRef readers on exception, not close them") made the NRT open exception-safe; LUCENE-6299 (#7360, 2015;
4e53fae38e9on its branch,33fad60de91on trunk) guarded the initial open only; LUCENE-9507 (48715fe898bf, 2021, #32) put the user callback into the constructor and passed it to the reopen factory's unguarded call. None of the 69 comments and reviews on LUCENE-9507, #32, apache/lucene-solr#2256 and #2473 mentions exceptions.
The devil's-advocate check123 words
The strongest case for "intended" (the devil's-advocate verifier and the intended-lens reviewer): the exception comes from the application's own comparator, so "fix your comparator"; nothing documents exception safety for callbacks; the factory is @lucene.internal (StandardDirectoryReader.java:168-173).
Why it fails: the path is public and untagged (DirectoryReader.open(Directory, Comparator), then openIfChanged); Comparator.compare may throw, and no javadoc of the leaf-sorter overloads or of IndexWriterConfig.setLeafSorter (IndexWriterConfig.java:517-527) asks for a sorter that never throws; a correct sorter that reads doc values and propagates an I/O error must wrap it in an unchecked exception; and Lucene already cleans up after this very failure on the initial open. The argument explains the Low priority, not the leak. Reviewers rated it weak to moderate.
Recommended Release the reopen factory's readers when the constructor throws (with a copy of the array)
In the reopen factory, wrap the constructor call in try/catch (Throwable), pass newReaders.clone() (the sort rearranges the array in place) and on failure decRef newReaders and rethrow, as the initial open does. Validated through gradle: the repro passes with assertions on and off; 104 tests of the closest suites pass.
SegmentReader[] newReaders = createSegmentReaders(infos, oldReaders, executor);
- return new StandardDirectoryReader(
- directory, newReaders, null, infos, leafSorter, false, false);
+ try {
+ // The constructor sorts the array it is given in place (leaf sorter), so pass a copy and
+ // release from the original if it throws:
+ return new StandardDirectoryReader(
+ directory, newReaders.clone(), null, infos, leafSorter, false, false);
+ } catch (Throwable t) {
+ decRefWhileSuppressingException(t, newReaders);
+ throw t;
+ }All options and trade-offs3 options ยท 574 words
The hunt's diff, unchanged, run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF) with the minimal repro and the closest suites.
Release the reopen factory's readers when the constructor throws (with a copy of the array) (validated by the checks) (recommended)
In StandardDirectoryReader.open(Directory, SegmentInfos, List, Comparator, ExecutorService) (StandardDirectoryReader.java:181-183), wrap the constructor call in try { ... } catch (Throwable t), pass newReaders.clone() to the constructor, and on failure call decRefWhileSuppressingException(t, newReaders) (:364-374, already used by createSegmentReaders) and rethrow. A shared reader goes back from 2 to 1 (the old reader keeps its own reference); a new reader goes from 1 to 0 and closes.
SegmentReader[] newReaders = createSegmentReaders(infos, oldReaders, executor);
- return new StandardDirectoryReader(
- directory, newReaders, null, infos, leafSorter, false, false);
+ try {
+ // The constructor sorts the array it is given in place (leaf sorter), so pass a copy and
+ // release from the original if it throws:
+ return new StandardDirectoryReader(
+ directory, newReaders.clone(), null, infos, leafSorter, false, false);
+ } catch (Throwable t) {
+ decRefWhileSuppressingException(t, newReaders);
+ throw t;
+ }Trade-offs
- Why the copy. The cleanup must see every reader exactly once, and
Arrays.sortrearranges the array it is given. A probe on JDK 25: when the comparator throws mid-sort, arrays of up to 31 elements stay a permutation (insertion sort), but from 32 elements on (TimSort merges) they can hold duplicates and miss elements, up to 7 of each at 32 elements and 15 at 64. Without the copy, the cleanup could release a shared reader twice and miss others. - Cost. One array copy per refresh; no API or behaviour change when the sorter succeeds.
- Also covers. Each original entry is decRef'd exactly once, so the readers are also released when the same constructor throws "Too many documents", and the NRT-reader-to-commit route passes the fact-check probe with the fix.
- Scope. Fixes the reopen leg only; the NRT deleter leg and the initial open's late-TimSort leg need the same treatment separately.
What the checks validated
Through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF): the minimal test fails on stock and passes with the fix, assertions on and off. The closest suites (TestDirectoryReader, TestDirectoryReaderReopen, TestIndexWriterReader, TestIndexReaderClose, TestReaderClosed, TestSearcherManager: 6 classes, 104 tests) pass on stock and with the fix (1 skipped both times); none of them makes a sorter throw. google-java-format 1.37.0 leaves the fix's lines and the test unchanged. Earlier, the hunt's shadow check, an independent verifier's controls and the refute reviewer's run (57 of 57 tests of TestDirectoryReader and TestIndexWriterReader) passed with the same diff.
Also pass a copy of the readers array in the initial open (its cleanup iterates the array being sorted) and, in the NRT open, release the deleter references taken by writer.incRefDeleter(segmentInfos) (StandardDirectoryReader.java:148) when the constructor throws. Not run.
Trade-offs
Fixes the two related legs as well, but it is three changes needing three tests, two of them (32+ segments; deleter state) harder to keep minimal.
Catch and decRef newReaders directly. Not recommended.
Trade-offs
Correct below 32 segments, but a late sorter failure on a larger reader leaves the array with duplicates and gaps, so the cleanup could release a shared reader twice, closing it under the old reader, and miss others.
Services that retry a failing refresh on a schedule, for example SearcherManager.maybeRefresh() from a refresh thread, leak once per attempt for as long as their sorter keeps failing on the same segment.
Formal: TLA+ model checking (TLC) of Lucene's reader reference counting and its open and reopen paths. With a "constructor throws" fault, the invariant that no reference leaks fails (27 and 137 distinct states) and the modelled fix passes. A static exception-path scan flagged the same call the same day; a public-API JUnit test confirmed it.
The method in full150 words
Method: formal, TLA+ model checking with TLC (a hunt unit, 2026-10-26), on a model of IndexReader reference counting and the StandardDirectoryReader open and reopen factories built from the code with a file:line map.
- With the
ctorThrowfault (theStandardDirectoryReaderconstructor throws, for example from the leaf sorter), theNoLeakinvariant fails in the sequential and executor configurations (27 and 137 distinct states); the same configurations with the modelled fix (decRef the new readers when the constructor throws) pass. - The fault edge was added while modelling the code, so a strict reading could call the discovery semi-formal.
- Found independently the same day by another hunt unit's static exception-edge resource scan: the
SegmentReader[]fromcreateSegmentReadersis live on the exception edge ofnew StandardDirectoryReader(...)with no handler. - A later hunt unit re-ran the model on
645d4fe14a20(violation without the fix, pass with it) and reduced the counterexample to the one-test repro.
No prior report found in GitHub issues, PRs and commits, open PRs' file lists, JIRA, the mailing lists, downstream projects and the web (2026-10-30), re-checked on GitHub on 2026-11-01. LUCENE-6299 (#7360) and LUCENE-9507 (#32) are the origin, not reports.
Searches3 searches
Context only: #6326 (LUCENE-5262, made the NRT open exception-safe), #7360 (LUCENE-6299, cleanup for the initial open only), #32 / #10546 (LUCENE-9507, introduced the leaf sorter; no review comment on exceptions), #11066 / #214 (LUCENE-10027, commit overloads with a leaf sorter), #15428 (2025 refactor; its cleanup discussion is about parallel segment opens), #9291 (LUCENE-8244) and #6718 (LUCENE-5656) (handle leaks on failure paths fixed as bugs elsewhere).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web | 2026-10-30 | three scripted prior-report searches (45, 51 and 45 queries) and 21 manual GitHub searches: leafSorter, "leaf sorter", openIfChanged + exception / leak / refCount / comparator, reopen + constructor + leak, LUCENE-6299 / 5656 / 8244; the review comments of #32 and apache/lucene-solr#2256 | no report; context #6326, #7360, #32, #10546, #11066, #15428, #9291, #6718 |
| Open and recently closed PRs (apache/lucene) | 2026-10-30 | changed files of all 409 open PRs and of the 24 PRs closed since 2026-10-27 | #16923, #16847 and #15556 touch StandardDirectoryReader.java, none its reopen factory |
| apache/lucene issues, PRs and commits | 2026-11-01 | the 30 issues and PRs updated since 2026-10-30, filtered for leaf, sorter, openIfChanged, reopen, refcount, DirectoryReader, SearcherManager, ReaderManager and leak; commits to the two fix-site files since 2026-10-28 | no report (#16900 is openIfChanged returning null after doc-values-only updates); no commit |
Introduced by LUCENE-9507 (48715fe898bf, 2021-03-26; branch_8x backport 1cfd0df4e7bb), which put the user's leaf sorter inside a constructor call the reopen factory never guarded. First released in 8.9.0; every release through 10.6.0 and main is affected (17 releases executed, the other 9.x/10.x tags by a static check). Not fixed upstream.
Versions tested6 rows
Introduced by 48715fe898bf, LUCENE-9507 "Custom order for leaves in IndexReader and IndexWriter (#32)" (#10546; author date 2021-03-26), backported to branch_8x as 1cfd0df4e7bb (apache/lucene-solr#2473, 2021-03-30). It added Arrays.sort(subReaders, subReadersSorter) to the BaseCompositeReader constructor and passed leafSorter into the reopen factory's final constructor call, which had been outside any try since at least LUCENE-4986 (2013); until then that call could only throw "Too many documents", which needs out-of-limit input.
LUCENE-6299 (4e53fae38e9, 2015) had guarded the initial open only. The 2025-11 refactor (#15428, 7d4e22454b53; #15445 backport, first released in 10.4.0) moved reader creation into createSegmentReaders and kept the unguarded call, so 10.4.0 is not a regression. A static check of the 33 9.x/10.x release tags up to 10.5.2 finds the call unguarded in each; 10.6.0, the 34th, has the same unguarded call and fails the minimal test. 9.2.0-9.12.2, 10.1.0-10.3.1, 10.5.0 and 10.5.1 are covered by the static check only. Upstream main 2f424fd428d (2026-10-30) has no commit to the fix-site files since 2026-10-28; the last change to StandardDirectoryReader.java is from 2026-02-11 (#15681).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails through gradle and standalone, assertions on and off; the finder's test and a core-only port fail too |
| 10.6.0 | affected | the minimal test fails on the release jars (-da, re-run 2026-11-01); the finder's test with -ea and -da |
| 10.5.2, 10.4.0, 10.3.2, 10.0.0, 9.12.3, 9.1.0, 9.0.0 | affected | an earlier, longer form of the minimal test (10.5.2, 9.12.3, 9.1.0), the finder's test or the core-only port fail on the release jars |
| 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4 | affected | the core-only port fails on the release jars (through the branch_8x backport) |
| 8.8.2 | not affected | DirectoryReader.open(Directory, Comparator) does not exist (compile error) |
main 645d4fe14a20 + fix | fix | the minimal test passes through gradle and standalone, assertions on and off |
Confirmed with high confidence by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6), none dissenting; confirmed by a credibility audit; re-run through gradle and on the 10.6.0 jars for this doc, with the fix validated.
Checks and verdicts398 words
Each checker that executed wrote or re-ran a test from the claim; the adjudication re-ran the independent reproducer's test with assertions on and off.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | TLC re-run on 645d4fe14a20: violation without the fix, pass with it. Its JUnit (a timestamp sorter on missing points, MockDirectoryWrapper oracle) fails on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3 and passes with the fix. |
| refute review (OpenAI GPT-5.6) | real, high | Executed. Its own FSDirectory test: reused/new refcounts 2/1 instead of 1/0 at three seeds, assertions on and off; successful-refresh control balanced; the fix passes it and 57 of 57 component tests. |
| intended review (OpenAI GPT-5.6) | real, medium | Read-only (it could not execute); root cause agrees. |
| Independent reproducer | real | Wrote the first, longer form of the minimal test (later reduced to the one shown here): fails on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3. Controls: a successful reopen is balanced, the initial open with a throwing sorter leaks nothing, a failed reopen then close leaves 2 files open. |
| Intended-behaviour devil's advocate | real, high | The "intended" case is weak to moderate and does not survive; re-ran the finder's test (fails, assertions on and off). |
| adjudication | confirmed, high | Re-ran the independent reproduction's test (assertions on and off) and the finder's test: all fail. Class minor. |
| credibility audit | confirmed, Low | The first form of the minimal test re-run with a new seed fails, assertions on and off; the audit confirmed it as a real, minor exception-safety bug. |
| this doc | real, fix validated | The minimal test fails through gradle (assertions on and off), standalone on main and on the 10.6.0 jars; the fix passes it and the 104 tests of the closest suites; a JDK probe shows why the fix copies the array; a GitHub prior-art refresh (2026-11-01) found nothing new. |
| fact checks of this doc | real; corrections applied | An independent fact checker re-ran the minimal test (assertions on and off, a second seed, the 10.6.0 jars, gradle), the fix and the closest suites with the same results, and showed by execution that an NRT reader refreshed to an explicit commit is hit too. A second, read-only fact check by OpenAI GPT-5.6 found the same route. Their corrections (that route, the executed-version count, the tag count, the leak-size wording) are applied. |
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 415 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,270 words
- Registry title
- DirectoryReader.openIfChanged on a reader opened with a leaf sorter leaks segment reader references when the sorter throws while the new reader is built, so closing the old reader no longer closes the shared segments and the new segment's reader is never closed
- Area
- core / index reader reopen (StandardDirectoryReader reopen factory behind every non-NRT openIfChanged, with a leaf sorter, LUCENE-9507)
- Priority
- Low, rank 1093: A bounded openIfChanged leak of segment reader references that happens only when an opt-in leaf sorter throws during a refresh, which is already a loud failure.
- Impact class
- minor
- Impact note
- Applications that open readers with the opt-in leaf sorter (DirectoryReader.open(Directory, Comparator) or a commit overload that takes one, since 8.9.0) and refresh them with openIfChanged, SearcherManager or ReaderManager: when the sorter throws during a refresh (a comparator bug on a segment that lacks the sort field, or an I/O error that a sorter reading per-segment data rethrows unchecked), the failed refresh keeps an extra reference on every segment reader it shares with the old reader and never closes the new segment's reader, so Lucene never releases their files and cores (executed: after closing the old reader, MockDirectoryWrapper still reports both segment files open); it repeats on every retry while the sorter keeps failing; the sorter failure itself is loud, search results are unaffected, any index size.
- Summary
- You open a DirectoryReader with a leaf sorter and later call DirectoryReader.openIfChanged; if the sorter throws while the new reader sorts its leaves, you expect only that exception, because the javadoc makes you responsible just for the old reader and the initial open releases its readers in exactly this case. Instead the reopen factory has already taken a reference on each reused segment reader and opened the new ones, and it builds the new reader with no try/catch, so after the failed call the shared segment reader's refCount is 2 instead of 1 (1 instead of 0 after you close the old reader) and the new segment's reader is never closed.
- Introduced
- 48715fe898bf0a900e3e6b681882ecb1b6d8e1b1 (main, 'LUCENE-9507 Custom order for leaves in IndexReader and IndexWriter (#32)'; apache/lucene#32, tracker LUCENE-9507 = #10546) and its branch_8x backport 1cfd0df4e7bb6180b67abb30bd92390bc729ec3f (apache/lucene-solr#2473) [read: history clone and GitHub; exec: 8.8.2 lacks the API, 8.9.0 leaks]. The commit put the application's leaf sorter (Arrays.sort in the BaseCompositeReader constructor) inside the constructor call that the reopen factory never guarded; the initial open already had a try/catch (LUCENE-6299, 2015) and the NRT open its own handler. Not a regression: the 2025-11 refactor (#15428 on main, #15445 backport, first released in 10.4.0) kept the unguarded call. (2021-03-26 (author date of 48715fe898bf on main); branch_8x backport 2021-03-30; first release 8.9.0 tagged 2021-06-16)
- First affected version
- 8.9.0 [exec] (first release with the leaf sorter, through the branch_8x backport: the core-only port leaks); 8.8.2 lacks DirectoryReader.open(Directory, Comparator) [exec: compile error]; first 9.x release 9.0.0 [exec]
- Affected versions
- every release from 8.9.0 through 10.6.0 and main 645d4fe14a20; never fixed. [exec] the minimal JUnit TestReopenSorterFailureRefCount fails on main (-ea and -da; re-run at the registration check 2026-10-30, seed DEADBEEF, 'expected:<1> but was:<2>'), on the 10.6.0 release jars (4ae946e5a9b, -da), 10.5.2, 9.12.3 and 9.1.0, and passes on main with the fix shadowed; the finder's JUnit fails on main (-ea, -da), 10.6.0 (-ea, -da), 10.5.2, 10.4.0, 10.3.2, 10.0.0, 9.12.3 and 9.1.0; a core-only main() port fails on 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.1.0, 10.3.2, 10.4.0, 10.6.0 and main (-ea, -da) and passes on main with the fix shadowed. [read] the reopen factory's final constructor call is unguarded in all 33 9.x/10.x release tags. Upstream main 716d6f216f5 (2026-10-29) and branch_10_6 have no commit to StandardDirectoryReader.java after 2026-02-11 or to BaseCompositeReader.java after 2025-07-30 (author dates). Not new in 10.6.
- 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
- formal: TLA+/TLC
- Upstream and prior reports
- none found (2026-10-30, read-only, on top of the finder's scripted prior-report search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches): three scripted prior-report searches (45, 51 and 45 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 19 manual GitHub searches, the changed files of all 409 open apache/lucene PRs and of the 24 closed since 2026-10-27 (none changes the reopen factory's constructor call), the review comments of #32 and apache/lucene-solr#2256, and the upstream history of the fix-site files with author dates. Refresh 2026-10-30: no new item; none of the 50 PRs updated since 2026-10-28 changes StandardDirectoryReader, BaseCompositeReader, DirectoryReader, ReaderManager or SearcherManager, none of the 10 issues created since then is about readers, and no commit to the fix site after 645d4fe. Related, not this bug: #6326 (LUCENE-5262, 2013, made the NRT open decRef readers on exception), #7360 (LUCENE-6299, 2015, added the try/catch to the initial open only), #32 / #10546 (LUCENE-9507, introduced the leaf sorter; no review comment on exceptions), #11066 / #214 (LUCENE-10027, commit overloads), #15428 (the 2025 refactor; its cleanup discussion is about parallel segment opens), #9291 (LUCENE-8244) and #6718 (LUCENE-5656) (precedents: handle leaks on failure paths fixed elsewhere).
- Lucene commit tested
- main
645d4fe14a20: the minimal test through gradle in a scratch worktree, stock and with the fix, assertions on and off, plus the closest suites stock and with the fix; the minimal test standalone with-eaand-da. The 10.6.0 release jars: the minimal test with-da(re-run 2026-11-01). The study's runs on 17 releases from 8.8.2 (API absent) through 10.6.0, plus a static check of the 9.x/10.x release tags up to 10.5.2. A fact-check probe (2026-11-01): an NRT reader refreshed to an explicit commit, stock (assertions on and off) and with the fix. Upstream main2f424fd428d(2026-10-30) has no change to the two fix-site files since 2026-10-28 (read).
- Finding record
lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:174-184 (at 645d4fe14a20) (reopen factory open(Directory, SegmentInfos, List, Comparator, ExecutorService), @lucene.internal, 'only used for doOpenIfChanged(SegmentInfos, ExecutorService) and NRT replication': createSegmentReaders at :181, then 'return new StandardDirectoryReader(...)' at :182-183 with no try/catch), :96-105 (initial open: 'it must be inside try clause so we close readers in that case', catch (Throwable t) { IOUtils.closeWhileSuppressingExceptions(t, readers); throw t; })lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-77 (at 645d4fe14a20) (constructor runs Arrays.sort(subReaders, subReadersSorter): the application's leaf sorter)lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:76-92 (at 645d4fe14a20) (open(Directory, Comparator leafSorter) javadoc and method), :230-245 (openIfChanged javadoc: 'The provided reader is not closed (you are responsible for doing so); if a new reader is returned you also must eventually close it')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:51-65, (at 645d4fe14a20) :96-105, :128-165, :174-184, :187-247 (:218-228, :241-244), :287-361 (:292-296, :317-323), :396-399, :477-511lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-77, (at 645d4fe14a20) :89-98lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:88-91, (at 645d4fe14a20) :245-249, :269-301, :536-541
- Who is affected
lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:88-91, (at 645d4fe14a20) :106-110, :189-194, :218-226lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:158, (at 645d4fe14a20) :194-205; lucene/core/src/java/org/apache/lucene/search/ReferenceManager.java:154-188 (a failed refreshIfNeeded leaves the current reference in place); lucene/core/src/java/org/apache/lucene/index/ReaderManager.java:82lucene/core/src/java/org/apache/lucene/index/LeafReader.java:175, (at 645d4fe14a20) :510-514lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:282-301; (at 645d4fe14a20) StandardDirectoryReader.java:155, :298-315, :406-431, :456-460, :496-511; SegmentInfos.java:833-904 (:884); lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:114-131, :199-200; search/RefreshCommitSupplier.java:28-38
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:236-243; (at 645d4fe14a20) IndexReader.java:164-174; IndexWriterConfig.java:517-527lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:96-105, (at 645d4fe14a20) :128-165, :168-173, :218-228, :241-244lucene/core/src/test/org/apache/lucene/index/TestDirectoryReaderReopen.java:673-735; (at 645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:873-888 (cannot close: there are still open files)
- TestReopenSorterFailureRefCount.java (one test, public API):
repro/LFV-1059/TestReopenSorterFailureRefCount.java(47 lines)
Made from the code at 645d4fe14a20 and the executed runs.
assets/LFV-1059/bug.png(415 KB)
