All findings · Lucene finding · LFV-1059 · reopen-leaf-sorter-failure-leaks-segment-readers

DirectoryReader.openIfChanged leaks segment reader references when the leaf sorter throws while the new reader is built

If a leaf sorter throws while openIfChanged builds the new reader, the failed call keeps a reference on every segment shared with the old reader and never closes the new segment's reader. The shared segment ends at refCount 2, not 1, so closing the old reader leaves its files open.

Summary

Priority
Low, rank 1093A 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
minor
Since
8.9.0 (LUCENE-9507, 48715fe898bf, 2021-03-26)introduced 2021-03-26 · about 5 years
Affected
8.9.0-10.6.0 and main; only readers that have a leaf sorter
Status
documented
Found by
formal: TLA+ model checking (TLC) of reader reference counting, then a public-API JUnit test

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Background: leaf sorters and shared segment readers

  1. A DirectoryReader is a list of segment readers, one per segment. DirectoryReader.open(dir, leafSorter) takes a Comparator<LeafReader> that orders them, for example newest segment first for time-based indices, so sorted queries can stop early.
  2. Segment readers are reference counted. openIfChanged builds 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.
  3. 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.
Diagram of the bug: a reader opened with a leaf sorter holds segment _0 at refCount 1. After a second commit, openIfChanged adds a reference to _0 (refCount 2) and opens _1 (refCount 1), then the new reader's constructor runs the sorter, which throws. The exception leaves openIfChanged and nothing releases the references, so after reader.close() _0 stays at refCount 1 and _1 is never closed; both segment files stay open. The fix wraps the constructor in try/catch and releases the references.
One refresh of a two-segment reader: 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.

The finding, step by step

  1. Commit one document (segment _0) and open DirectoryReader.open(dir, sorter), where sorter is a Comparator<LeafReader> that orders the segments. One segment needs no comparison, so the open succeeds: _0's reader has refCount 1.
  2. Commit a second document (segment _1) and call DirectoryReader.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.
  3. You expect only the exception. No new reader was returned, so _0 should be back at refCount 1 and closing reader should close it. The first open cleans up after exactly this failure.
  4. Instead _0's refCount is 2 after the failed call and still 1 after reader.close(), and _1's new reader is never closed: both segment files, _0.cfs and _1.cfs, stay open. Every retried refresh leaks again.

Who is affected

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.

Terms

  • Leaf sorter: the Comparator<LeafReader> given to DirectoryReader.open(Directory, Comparator) or a commit overload (LUCENE-9507). It is null by default. The reader keeps it as subReadersSorter and uses it for every refresh.
  • Reopen factory: StandardDirectoryReader.open(Directory, SegmentInfos, List, Comparator, ExecutorService) (StandardDirectoryReader.java:174-184 at 645d4fe14a20), @lucene.internal, behind every openIfChanged of a reader that was not opened from an IndexWriter (also the ExecutorService and IndexCommit overloads), and behind openIfChanged(reader, IndexCommit) of an NRT reader.

The bug

  1. openIfChanged(reader) reaches doOpenNoWriter (StandardDirectoryReader.java:477-494), doOpenFromCommit (:496-505) and doOpenIfChanged(SegmentInfos, ExecutorService) (:507-511), which calls the reopen factory with the reader's own sorter.
  2. 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 gets new SegmentReader(...) (:292-296), refCount 1. createSegmentReaders releases these itself only when one of its own steps fails (:218-228, :241-244).
  3. Then return new StandardDirectoryReader(directory, newReaders, null, infos, leafSorter, false, false); (:182-183) runs with no try/catch. Through DirectoryReader's constructor (DirectoryReader.java:539) it reaches BaseCompositeReader, which runs Arrays.sort(subReaders, subReadersSorter) (BaseCompositeReader.java:75-77): the application's comparator.
  4. The comparator throws, openIfChanged throws, and the array newReaders is gone: the caller has no handle on the extra reference or on the new reader.
  5. 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 catches Throwable and decRefs its readers (:128-165).

The numbers

step_0 refCount, expected_0 refCount, actual
after DirectoryReader.open(dir, sorter) (one segment)11
after the failed openIfChanged (two segments)12
after reader.close()01

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), its ExecutorService overload (:106-110) or the commit overloads (:189-194, :218-226), refreshed with openIfChanged, SearcherManager(DirectoryReader, SearcherFactory) (SearcherManager.java:158; refreshIfNeeded calls openIfChanged, :194-205) or ReaderManager(DirectoryReader) (ReaderManager.java:82); a fact-check probe ran all three. Also NRT readers from an IndexWriter with IndexWriterConfig#setLeafSorter when they are refreshed to an explicit commit: openIfChanged(reader, IndexCommit) (DirectoryReader.java:282-301) goes to doOpenFromWriter, which for a non-null commit calls doOpenFromCommit and so the same reopen factory with the writer's leaf sorter (StandardDirectoryReader.java:155, :406-431, :456-460); a SearcherManager(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, null by default. Plain NRT refreshes (openIfChanged(reader) or openIfChanged(reader, writer) on an NRT reader, or a SearcherManager whose refresh commit supplier returns null, 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 is null for such a segment, LeafReader.java:510-514), or an I/O error inside a correct sorter: Comparator.compare cannot throw checked exceptions, while per-segment readers such as getNumericDocValues declare IOException (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 with IndexWriterConfig#setLeafSorter keeps the IndexFileDeleter references 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 that Arrays.sort was rearranging, so a late sorter failure in a TimSort merge (32+ segments) leaks some readers ("still 4 open files").

Minimal repro

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.

TestReopenSorterFailureRefCount.java (one test, public API)47 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.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();
  }
}
Command
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 by reader.
Observed
AssertionError: expected:<1> but was:<2>: the failed call kept its reference; assertions on and off on main, and on 10.6.0.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# 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}

Why it looks unintended

  • The initial open wraps the same constructor call in catch (Throwable): "it must be inside try clause so we close readers in that case".
  • openIfChanged makes the caller own only the reader passed in and a returned one; a failed call returns none.
  • testOverDecRefDuringReopen expects a failed openIfChanged to 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) and createSegmentReaders (: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) makes openIfChanged fail with a FakeIOException, checks that the old reader still works and closes its MockDirectoryWrapper, 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; 4e53fae38e9 on its branch, 33fad60de91 on 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.

Suggested fix

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.sort rearranges 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.

One exception-safe pattern for all three open paths

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.

The same try/catch without the copy

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.

Downstream note

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.

How it was found

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 ctorThrow fault (the StandardDirectoryReader constructor throws, for example from the leaf sorter), the NoLeak invariant 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[] from createSegmentReaders is live on the exception edge of new 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.

Prior reports, history and checks

Prior reports

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

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web2026-10-30three 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#2256no report; context #6326, #7360, #32, #10546, #11066, #15428, #9291, #6718
Open and recently closed PRs (apache/lucene)2026-10-30changed 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 commits2026-11-01the 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-28no report (#16900 is openIfChanged returning null after doc-values-only updates); no commit

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe 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 affectedthe 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 affectedan 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 affectedthe core-only port fails on the release jars (through the branch_8x backport)
8.8.2 not affectedDirectoryReader.open(Directory, Comparator) does not exist (compile error)
main 645d4fe14a20 + fix fixthe minimal test passes through gradle and standalone, assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTLC 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, highExecuted. 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, mediumRead-only (it could not execute); root cause agrees.
Independent reproducer realWrote 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, highThe "intended" case is weak to moderate and does not survive; re-ran the finder's test (fails, assertions on and off).
adjudication confirmed, highRe-ran the independent reproduction's test (assertions on and off) and the finder's test: all fail. Class minor.
credibility audit confirmed, LowThe 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 validatedThe 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 appliedAn 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.

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,270 words

Finding record

LFV-1059, snapshot of 2026-11-06

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 -ea and -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 main 2f424fd428d (2026-10-30) has no change to the two fix-site files since 2026-10-28 (read).

Lucene and upstream sources

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-511
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-77, (at 645d4fe14a20) :89-98
  • lucene/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-226
  • lucene/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:82
  • lucene/core/src/java/org/apache/lucene/index/LeafReader.java:175, (at 645d4fe14a20) :510-514
  • lucene/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-527
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:96-105, (at 645d4fe14a20) :128-165, :168-173, :218-228, :241-244
  • lucene/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)

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

Repro files

About the diagrams

Made from the code at 645d4fe14a20 and the executed runs.

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.