All findings · Lucene finding · LFV-131 · reopen-ignores-min-supported-major

A reader opened with the expert lower-minimum DirectoryReader.open forgets that minimum, so isCurrent(), openIfChanged() and maybeRefresh() throw on an unchanged index

The expert DirectoryReader.open(commit, minSupportedMajorVersion, leafSorter) opens an index from an older major, but the reader forgets that minimum: isCurrent(), openIfChanged() and SearcherManager.maybeRefresh() re-check the unchanged commit against the default minimum and throw IndexFormatTooOldException on every call. Since 9.0.0; keeping the minimum on the reader fixes it.

Summary

Priority
Low, rank 783Since 9.0, readers opened with the expert lower-minimum open throw IndexFormatTooOldException on every isCurrent, openIfChanged and maybeRefresh; loud, a reopen works around it, and the known users of that API never refresh.
Impact
crash
Since
9.0.0 (LUCENE-9669, 2021)introduced 2021-01-19 · about 5 years
Affected
every release 9.0.0 through 10.6.0, branch_11x (11.0) and main
Status
documented
Found by
bounded-exhaustive translation validation (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The minimum you pass to the expert open is range-checked and handed to the one call that reads the commit, and then dropped. Every later "has the index changed?" check re-reads the commit with the default minimum and rejects the commit the reader already has open.

Three things to know first

  1. Each segment records minVersion, the oldest Lucene version that wrote any of its documents. Rewriting a segment (a merge, IndexUpgrader) keeps the oldest one, so an 8.x index upgraded by 9.x still says 8.x.
  2. A normal open refuses a commit when any segment's minVersion major is below Version.MIN_SUPPORTED_MAJOR, which is 9 on 10.x, 11.0 and main (before 10.4.0 the check used the index's created major instead).
  3. The expert DirectoryReader.open(commit, minSupportedMajorVersion, leafSorter) lowers that minimum for one reader, so such an index can be read (read-only: an IndexWriter still refuses it).
Diagram of the bug: an index whose segment records minVersion 8.11.4 is opened with DirectoryReader.open(commit, 8, null); isCurrent(), openIfChanged() and SearcherManager.maybeRefresh() re-read the same, unchanged commit with the default minimum 9 and throw IndexFormatTooOldException; with the fix the reader keeps minimum 8 and isCurrent() returns true.
The reader is opened with minimum 8, but every "has the index changed?" check re-reads the unchanged commit against the default minimum 9 and throws; with the fix the reader keeps its 8. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Take Lucene's own 8.11.4 back-compat test index (35 documents) after Lucene 9.12.3's IndexUpgrader: one segment written by 9.12.3 that still records minVersion 8.11.4. Lucene 11 supports majors 9 and later, so the default DirectoryReader.open(dir) refuses it.
  2. Open it with the expert DirectoryReader.open(commit, 8, null): it opens, with 35 documents, and searches work.
  3. Write nothing, then call reader.isCurrent(), DirectoryReader.openIfChanged(reader) or SearcherManager.maybeRefresh(). You expect true, null and a no-op: nothing was committed.
  4. Instead all three throw IndexFormatTooOldException ("Index has segments derived from Lucene version 8.x ... found: 8, minimum supported: 9") from isCurrent(), on every call: the reader re-read its own commit with the default minimum 9, not the 8 it was opened with.
  5. The open reader keeps serving its 35 documents, but it can never be refreshed: to move on, you have to call open(commit, 8, null) again on the latest commit.

Who is affected

Applications that open an older-major index with the expert lower-minimum open and then check or refresh it (isCurrent(), openIfChanged(), a SearcherManager / ReaderManager). Main: 8.x-created indexes rewritten by 9.x/10.x; 10.4-10.6: a segment derived from 8.x; 10.0-10.3 and 9.x: any index created by 8.x / 7.x. Loud: every refresh throws, the open reader keeps working.

Mechanism, terms and numbers534 words

DirectoryReader.open(IndexCommit, int minSupportedMajorVersion, Comparator<LeafReader>) (expert, since 9.0) opens an index whose segments come from a major below Version.MIN_SUPPORTED_MAJOR, which the default open refuses. The open range-checks the minimum and passes it to the one call that reads the commit; the reader does not keep it. isCurrent() and the reopen path later re-read the commit through the SegmentInfos overloads that apply the default Version.MIN_SUPPORTED_MAJOR, so the per-segment version gate rejects the very commit the reader already has open. With nothing committed since the open, isCurrent(), openIfChanged(reader) and SearcherManager / ReaderManager.maybeRefresh() all throw IndexFormatTooOldException on every call. The open reader keeps serving its commit (no wrong results, no data loss); to see a newer commit, the application has to call the expert open again (the old reader can stay open until the new one replaces it). The overload's other parameter, leafSorter, is carried to reopened readers; the minimum is not. Keeping the minimum on the reader and passing it to the two re-reads fixes it. Every release from 9.0.0 through 10.6.0 is affected, and so are branch_11x and main.

The bug (main 645d4fe14a20)

  1. DirectoryReader.open(commit, minSupportedMajorVersion, leafSorter) (DirectoryReader.java:189-194) calls StandardDirectoryReader.open(directory, minSupportedMajorVersion, commit, leafSorter, null) (StandardDirectoryReader.java:77-108). It range-checks the minimum (:87-93), passes it to the one call that reads the commit, SegmentInfos.readCommit(directory, segmentFileName, minSupportedMajorVersion) (:94-95), and builds the reader with new StandardDirectoryReader(directory, readers, null, sis, leafSorter, false, false) (:100-101). The reader has no field for the minimum (:45-48): it is dropped here.
  2. isCurrent() (:529-544), for a reader without a writer, re-reads the latest commit with SegmentInfos.readLatestCommit(directory) (:537), which is readLatestCommit(directory, Version.MIN_SUPPORTED_MAJOR) (SegmentInfos.java:581-583).
  3. openIfChanged(reader) goes to doOpenNoWriter(null, ...), which calls isCurrent() first (StandardDirectoryReader.java:477-482). SearcherManager.refreshIfNeeded (SearcherManager.java:194-206) and ReaderManager.refreshIfNeeded (ReaderManager.java:92-94) call openIfChanged.
  4. A reopen onto a different commit goes through doOpenFromCommit (StandardDirectoryReader.java:496-505), which reads it with SegmentInfos.readCommit(directory, segmentFileName) (:501), also the default minimum (SegmentInfos.java:293-296). openIfChanged(reader, newerCommit) reaches it directly; openIfChanged(reader) reaches it only after isCurrent() (StandardDirectoryReader.java:479-482), which already throws while the latest commit is one the default gate refuses.
  5. The per-segment gate (SegmentInfos.java:523-545) throws IndexFormatTooOldException when a segment's minVersion major is below the minimum it is given (:529). With the default 9 it rejects the commit the reader opened with 8.
  6. openIfChanged(reader, reader.getIndexCommit()) does return null: it short-circuits on the segments file name before reading anything (StandardDirectoryReader.java:487-490).
  7. The overload's other parameter survives: doOpenIfChanged(SegmentInfos, ...) passes subReadersSorter, the leafSorter, to the reopened reader (:507-511, from LUCENE-9507). There is no minimum to pass.

The numbers

Lucene's unsupported.8.11.4-cfs.zip back-compat fixture, upgraded by the stock 9.12.3 IndexUpgrader: created major 8, one segment written by 9.12.3 with minVersion 8.11.4, 35 documents. On main 645d4fe14a20 the default open throws and open(commit, 8, null) opens 35 documents; then isCurrent(), openIfChanged(reader) and SearcherManager.maybeRefresh() throw "Index has segments derived from Lucene version 8.x and is not supported by Lucene 11.0.0. This Lucene version only supports indexes with major version 9 or later (found: 8, minimum supported: 9)" from StandardDirectoryReader.isCurrent:537. With the fix all three return the contract values.

Exposure and likelihood353 words
  • Who is hit. Applications that keep an archived or read-only older-major index searchable after an upgrade, open it with DirectoryReader.open(commit, min, leafSorter) (or its ExecutorService variant), and then call isCurrent(), openIfChanged(), or refresh it through a SearcherManager / ReaderManager. On 11.0 and main (minimum 9) the demonstrated case is an index created by 8.x whose segments were all rewritten by 9.x or 10.x, for example by IndexUpgrader; 8.x's default codec, Lucene87, is no longer shipped there (an application that supplies an older codec itself could reach it with other old indexes too, by reasoning). The line splits at 10.4.0, where #14607 moved the default gate from the commit's created major to each segment's minVersion: on 10.4-10.6 the index is one with a segment derived from 8.x (an 8.x-created index whose segments all have minVersion 9 or later opens by default there and is not hit); on 10.0-10.3 it is any index created by 8.x; on 9.x any index created by 7.x.
  • Opt-in. Default opens and NRT readers are not affected; the way in is the expert open (the public but @lucene.internal StandardDirectoryReader.open(Directory, SegmentInfos, ...), given a SegmentInfos read with a lower minimum, builds the same kind of reader, StandardDirectoryReader.java:168-184).
  • Scale. None: one document is enough, and every call fails, with assertions on and off.
  • Loud, no data damage. A clean IOException on every refresh. The open reader keeps serving exactly its commit: no wrong results, no lost data, no file changes. A probe of 1,500 lifecycle calls on an unchanged index and 901 after a newer commit found no file change, no open input, no ref-count drift, no lock retention, and a newer commit was never reported as current.
  • Why nobody reported it, probably (an inference). The known users of the API open such readers once and never refresh them: Elasticsearch's ReadOnlyEngine (refresh() returns NO_REFRESH), OpenSearch's earlier ReadOnlyEngine (OpenSearch#5429) and a proposed Pinot fallback (apache/pinot#19894, an open PR).
  • Workaround. Call open(commit, min, leafSorter) again on the latest commit, then close the old reader (executed by the devil's advocate with the old reader still open).

Minimal repro

The test shows that a reader opened with the expert DirectoryReader.open(commit, minSupportedMajorVersion, leafSorter) (minimum 8) on an index whose segment is recorded as derived from Lucene 8.x cannot check itself: isCurrent() re-reads the commit with the default minimum (9).

TestReopenBelowMinSupportedMajor.java (JUnit test)51 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 org.apache.lucene.document.Document;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.Version;

public class TestReopenBelowMinSupportedMajor extends LuceneTestCase {

  public void testIsCurrentOnUnchangedIndex() throws Exception {
    int oldMajor = Version.MIN_SUPPORTED_MAJOR - 1;
    try (Directory dir = FSDirectory.open(createTempDir())) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        w.addDocument(new Document());
      }
      SegmentInfo si = SegmentInfos.readLatestCommit(dir).info(0).info;
      si.minVersion = Version.fromBits(oldMajor, 11, 4);
      dir.deleteFile(si.name + ".si");
      si.getCodec().segmentInfoFormat().write(dir, si, IOContext.DEFAULT);

      IndexCommit commit = DirectoryReader.listCommits(dir).get(0);
      try (DirectoryReader reader = DirectoryReader.open(commit, oldMajor, null)) {
        try {
          assertTrue(reader.isCurrent());
        } catch (IndexFormatTooOldException e) {
          throw new AssertionError("expected isCurrent() == true on the unchanged index, got " + e, e);
        }
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-131/, 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.TestReopenBelowMinSupportedMajor
Expected vs actual
Expected isCurrent() to return true on the unchanged index; actual: IndexFormatTooOldException ("Index has segments derived from Lucene version 8.x ... minimum supported: 9"), with -ea and -da.

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

More files, environment, full output8 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Why it looks unintended

  • isCurrent() "checks if any further commits ... have occurred"; openIfChanged returns "null if there are no changes" (DirectoryReader.java:628-629, :242). Nothing changed, yet both throw.
  • LUCENE-9669 sets "a minimum index version on a per reader basis"; read-only meant no IndexWriter, not no refresh.
  • The same overload's leafSorter survives reopen (StandardDirectoryReader.java:510), and that is tested; the minimum does not.
The full argument368 words
  • Contract. openIfChanged (DirectoryReader.java:228-229): "If the index has changed since the provided reader was opened, open and return a new reader; else, return null"; @return null if there are no changes (:242). isCurrent (:628-629): "If this reader was created by calling open, then this method checks if any further commits (see IndexWriter#commit) have occurred in the directory." The expert overload is one of those open methods; neither contract excludes it.
  • The expert javadoc (DirectoryReader.java:169-177) says the method "allows to open indices that were created with a Lucene version older than MIN_SUPPORTED_MAJOR" and "All backwards compatibility aside from the file format is optional and applied on a best effort basis." The failure is the file-format version gate itself, on the commit that already opened.
  • Intent. The introducing commit c1ae6dc07c9 (LUCENE-9669): "adds an API on DirectoryReader to specify a minimum index version on a per reader basis. This doesn't apply for the IndexWriter which will fail on opening older indices." CHANGES 9.0 (lucene/CHANGES.txt:5015-5020): such indexes open "in read-only mode", and "Opening an index created with a major version of N-2 with an IndexWriter is not supported."
  • Sibling parameter. leafSorter, from the same overload, is passed to reopened readers (StandardDirectoryReader.java:510), and that is tested with this very overload: in TestIndexWriterReader.testIndexReaderWriterWithLeafSorter, "Test4" opens DirectoryReader.open(latestCommit, Version.MIN_SUPPORTED_MAJOR, leafSorter) and checks the leaf order after openIfChanged (TestIndexWriterReader.java:1305-1328; open at :1311, reopen and check at :1324-1325). It is the only existing test that reopens a reader from the expert open, and it passes the default minimum, so it cannot catch this bug.
  • Tests. No test reopens or calls isCurrent on a lower-minimum reader: TestBasicBackwardsCompatibility.testFailOpenOldIndex opens and closes (:856-871); TestDirectoryReader.testOpenWithInvalidMinCompatVersion (:1195-1207) and testOpenCommitWithMinVersionAndExecutor (:1267-1286) open current indexes and close them.
  • History. Before c1ae6dc07c9 every open used the same default gate as the two re-reads (last rewritten by c4bf601b68f, LUCENE-5969, 2014), so a re-read could not reject a reader's own commit. That commit added the minimum to the initial open only; nobody in its PR (21 inline review comments), the issue or the dev@ RFC thread discussed reopen, isCurrent or refresh.
The devil's-advocate check210 words

The strongest works-as-designed case (the devil's advocate built it and rejected it; the cross-model reviews rated it moderate): the overload is "Expert" and meant for one-shot read-only access; it promises only file-format compatibility and makes the rest "best effort"; the minimum has no getter and is not documented as a property of the reader; isCurrent and openIfChanged declare IOException; the main downstream user never refreshes; and a workaround exists (open again).

Why it fails (our reading of the sources): "best effort" reads as covering semantics on top of the file format (the design thread's examples are analysis and the encoding of norms), not the format gate (no document says so in as many words, and none says the minimum is kept across reopen either); read-only was defined as "you cannot open an IndexWriter on it"; the lifecycle contracts do not exclude these readers, and the failure needs no change to the index at all; the introducing commit calls the minimum per reader; the overload's other parameter survives reopen; and a stock released Lucene produces the trigger state. Verdict: a defect. Residual risk: a maintainer may prefer to document the limit instead. Upstream, it reads best as a reader-lifecycle inconsistency (a wrong error on an unchanged index), not a crash.

Suggested fix

Recommended Keep the minimum on the reader and use it in the two re-reads (validated)

Keep the minimum on the reader: a final minSupportedMajorVersion field set through the constructor, passed to readLatestCommit in isCurrent() and to readCommit in doOpenFromCommit, and handed to reopened readers like the leafSorter. +29/-7 in StandardDirectoryReader.java; the repro then passes, and so do all 8,898 lucene/core tests.

  // the minimum major version this reader was opened with: isCurrent() and reopen use it too
  private final int minSupportedMajorVersion;

  // isCurrent()
  SegmentInfos sis = SegmentInfos.readLatestCommit(directory, minSupportedMajorVersion);

  // doOpenFromCommit
  final SegmentInfos infos =
      SegmentInfos.readCommit(directory, segmentFileName, minSupportedMajorVersion);

  // doOpenIfChanged(SegmentInfos, ...): carry it to the new reader, like subReadersSorter
  return StandardDirectoryReader.open(directory, infos, getSequentialSubReaders(),
      subReadersSorter, executorService, minSupportedMajorVersion);
All options and trade-offs3 options ยท 598 words

One fix was built and validated with gradle; a smaller variant was validated by the hunt; documenting the limit is the alternative.

Keep the minimum on the reader and use it in the two re-reads (validated) (recommended)

A final field set through the package-private constructor. The expert open passes its argument; the NRT path and the public @lucene.internal open(Directory, SegmentInfos, ...) (used by NRT replication) pass Version.MIN_SUPPORTED_MAJOR; the reopen path (doOpenIfChanged(SegmentInfos, ...)) passes the reader's own value to the new reader through a private overload; isCurrent() and doOpenFromCommit use it. +29/-7, in StandardDirectoryReader.java only. The core of it:

  // the minimum major version this reader was opened with: isCurrent() and reopen use it too
  private final int minSupportedMajorVersion;

  // isCurrent()
  SegmentInfos sis = SegmentInfos.readLatestCommit(directory, minSupportedMajorVersion);

  // doOpenFromCommit
  final SegmentInfos infos =
      SegmentInfos.readCommit(directory, segmentFileName, minSupportedMajorVersion);

  // doOpenIfChanged(SegmentInfos, ...): carry it to the new reader, like subReadersSorter
  return StandardDirectoryReader.open(directory, infos, getSequentialSubReaders(),
      subReadersSorter, executorService, minSupportedMajorVersion);

Trade-offs

  • One int per reader. No change for readers opened with the default minimum (the field is then Version.MIN_SUPPORTED_MAJOR, exactly what the re-reads use today) or for NRT readers.
  • A reader opened with a stricter minimum (above the default) now also applies it on refresh, which is what a fresh open(commit, min, sorter) of the new commit would do; that is a behaviour change maintainers may want to decide on explicitly.
  • No getter is added; one could be, but nothing needs it.

What the checks validated

  • Gradle on 645d4fe14a20, fresh scratch worktrees, seed DEADBEEF: stock, the test fails with assertions on and off. With the fix: the test plus TestDirectoryReader, TestDirectoryReaderReopen, TestIndexWriterReader (incl. the leaf-sorter reopen test) and TestSearcherManager, 5 suites, 101 tests, 0 failures, 1 skipped; the test with assertions off passes.
  • With the fix, every lucene/core test: 748 test classes, 8,898 tests, 0 failures, 322 skipped. TestBasicBackwardsCompatibility (lucene/backward-codecs, which opens each older back-compat index with the expert open): 816 tests, 0 failures, 2 skipped. lucene/replicator, the other caller of the public open(Directory, SegmentInfos, ...), compiles, but its 15 tests are nightly or nested-only and were skipped.
  • :lucene:core:checkGoogleJavaFormat passes on the fix and the test.
  • Standalone runner: the test passes with the fix (-ea, -da); the independent reproducer's 7-test lifecycle class (SearcherManager, openIfChanged(reader, newerCommit), isCurrent after a newer commit) goes from 5 failures to 0; its real-index test on the 9.12.3-upgraded 8.11.4 index from 3 failures of 4 to 0.

Same idea with a mutable field set after construction (validated by the hunt)

The finder's sketch (+14/-6) and the independent reproducer's (+16/-6): a non-final private int minSupportedMajorVersion = Version.MIN_SUPPORTED_MAJOR; assigned after new StandardDirectoryReader(...) in the expert open and after open(...) in doOpenIfChanged (the finder with a cast, the reproducer with an instanceof check), plus the same two re-read changes.

Trade-offs

Same behaviour and a smaller diff, but the field is written after construction and needs a cast or an instanceof check; the recommended option keeps it final.

What the checks validated

The finder's 2 tests, the independent reproducer's 7 synthetic and 4 real-index tests, the reviewers' own tests, TestDirectoryReader (34/34) and TestDirectoryReaderReopen (30/30) pass with it (standalone runner, ea57360912ed), the one-test repro passes on 645d4fe14a20 with assertions on and off, and a 9.0.0 backport passes on 9.0.0.

Document the limitation (not built)

Say in the expert javadoc that a reader opened with a lower minimum cannot be refreshed and must be re-opened with the same call.

Trade-offs

Leaves isCurrent() throwing on an unchanged index, against its own contract, and SearcherManager / ReaderManager unusable for such readers.

Downstream note

A product that serves archived older-major indexes through this expert open and refreshes them with a SearcherManager sees every refresh fail until it re-opens the reader; Elasticsearch's read-only engine opens such readers but never refreshes them.

How it was found

Bounded-exhaustive translation validation of Lucene's index-version gate: 336 synthesized commits times 28 operations on the stock jar, compared with a truth table derived from the source. The two operations "isCurrent / openIfChanged on a reader opened with a lower minimum", added from code reading, failed on every commit whose segments come from an older major.

The method in full125 words

Method: semi-formal, bounded-exhaustive translation validation (hunt unit "backcompat 9.x policy", main ea57360912ed, 2026-10-25).

  • A harness synthesized 336 commits (6 created majors x 7 segment versions x 8 minVersion values including null) and ran 28 operations on each against the stock main jar, comparing every outcome with a truth table derived from the source.
  • The operations "isCurrent and openIfChanged on a reader opened with a lower minimum" mismatched on all 21 structurally valid tuples whose segments' minVersion major is below MIN_SUPPORTED_MAJOR.
  • Those reopen operations were added to the harness from code reading of StandardDirectoryReader while modelling the gate: the hunt chose to look there, and the harness showed that every such reader fails. A deterministic JUnit followed, later reduced to the one-test repro.

Prior reports, history and checks

Prior reports

No prior report found: 175 queries over Lucene's GitHub issues and PRs, the JIRA archive, the mailing lists, downstream trackers and the web (2026-10-26), re-checked on GitHub on 2026-10-31. The design discussions of the API never mention reopen or refresh.

Searches5 searches

The recorded searches found no report, discussion or fix. The two big downstream users never refresh these readers, which probably explains the silence. #16741 (merged 2026-10-15) raised whether the expert minimum-version APIs are still needed; a follow-up that may remove the read-side gate on main was announced; the read-only search of 2026-10-31 found no such PR.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues/PRs incl. the JIRA archive, commits, downstream, dev@ / java-user@ / issues@ / commits@, JIRA, web)2026-10-2628 GitHub issue/PR, 3 commit, 2 downstream, 32 mailing-list, 4 JIRA and 6 web queries: minSupportedMajorVersion, MIN_SUPPORTED_MAJOR, IndexFormatTooOldException with openIfChanged / isCurrent / SearcherManager / maybeRefresh / reopen, readLatestCommit, doOpenFromCommit, "segments derived from", LUCENE-9669no report: 777 distinct hits, 2 rated high and 50 medium; those 52 triaged by title and match note, and every hit touching the version gate read in full
GitHub GraphQL: apache/lucene, apache/solr, Elasticsearch, OpenSearch, all of GitHub2026-10-2660 queries, each read to the end; every item that mentions the minimum-version API read in fullno report; related design items only
Open apache/lucene PRs2026-10-26; re-checked 2026-10-31changed-file lists of all 413 open PRs; on 2026-10-31, PRs touching StandardDirectoryReader created since 2026-10-20none changes the two calls; no new PR
Design discussions2026-10-26the 2021 dev@ RFC thread (11 mails), lucene-solr#2212 (all review comments), #10708, LUCENE-10027 (#11066 / #214), #14607 (40 comments), #16741nobody discusses reopen, isCurrent or refresh for these readers
GitHub issue/PR search, apache/lucene2026-10-31minSupportedMajorVersion; isCurrent IndexFormatTooOldException; SearcherManager IndexFormatTooOldException; openIfChanged minimum version; MIN_SUPPORTED_MAJOR reopen; readLatestCommit isCurrent; the #16741 author's PRs since 2026-10-15only the known #16741, #14607, #214, #11066, #10546; 0 hits otherwise; no follow-up PR found

History

Introduced in 9.0.0 by LUCENE-9669 (c1ae6dc07c9, 2021-01-19), which added the per-reader minimum to the initial open only; the two default-minimum re-reads date from 2014. Every release from 9.0.0 through 10.6.0 is affected, plus branch_11x and main; 8.x has no such API.

Versions tested8 rows

Introduced by c1ae6dc07c9 "LUCENE-9669: Add an expert API to allow opening indices created < N-1 (#2212)" (s1monw, 2021-01-19; apache/lucene-solr#2212, approved by jpountz and mikemccand; issue apache/lucene#10708). It added the minimum to the initial open only; the re-reads in isCurrent and doOpenFromCommit were last rewritten by c4bf601b68f (LUCENE-5969, 2014), when every open used the same gate. Later: LUCENE-9507 (2021) carried leafSorter through reopen; LUCENE-10027 (#214, 2021) gave the overload its current form; #15428 / #15445 (10.4.0) added an ExecutorService variant with the same code path; #14607 (dd70ed5801e, 10.4.0) moved the default gate from the commit's created major to each segment's minVersion, which changed which indexes the default open refuses, and so which indexes reach the bug (and the message), not the bug itself. Upstream on 2026-10-31: main is 12.0.0-SNAPSHOT, branch_10x was decommissioned and replaced by branch_11x, 10.6.0 is released; none of them changes the two calls.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (11.0.0-SNAPSHOT) affectedgradle: the one-test repro fails with assertions on and off; the real 9.12.3-upgraded 8.11.4 index fails 3 of 4
10.6.0 affectedthe one-test repro fails on the released jars, with assertions on and off
10.0.0, 10.3.2, 10.4.0, 10.5.2 affectedsynthetic repro fails 2/2; real 8.11.4 fixture fails 3/4 on 10.0.0 and 10.5.2; the 9.12.3-upgraded index fails on 10.3.2, 10.4.0, 10.5.2
9.0.0, 9.8.0, 9.12.3 affectedsynthetic repro fails 2/2; the real 7.7.3 fixture fails 3/4 on 9.0.0 and 9.12.3; a 9.0.0 backport of the fix passes
c1ae6dc07c9 (LUCENE-9669) vs its parent 426c902bc96 introducing commitfails 2/2 with -ea and -da; the parent has no such API (compile error)
8.11.4 and older not affectedno lower-minimum open exists (compile error; javap of the 8.8.0 and 8.11.4 jars)
branch_11x 603af870375, upstream main 2f424fd428da, branch_10_6 7e5aec4370e readsame two default-minimum calls (byte-identical file on branch_11x and main), read 2026-10-31
fix (645d4fe14a20 + fix.diff) not affectedthe repro and the real-index test pass; all 8,898 lucene/core tests pass

Independent checks

Confirmed by an independent reproducer, executing reviews from another model family (OpenAI GPT-5.6), a devil's advocate and an adjudication, each with their own runs and controls. This doc's test was re-run with gradle on main and on the released 10.6.0 jars, and the fix was validated with gradle.

Checks and verdicts346 words

Every executing check wrote or ran its own test on the stock jars, with passing controls (the default open throws, the expert open succeeds, openIfChanged(reader, sameCommit) returns null); the fix passes each of their tests. The reviews, the reproducer, the devil's advocate and the adjudication ran on the hunt's earlier main checkout ea57360912ed, where the three files have the same lines.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn 7-test class fails 5/7 on main (-ea, -da), 10.5.2 and 9.12.3, both controls pass; real fixtures fail 3/4 (8.11.4 on 10.5.2, 7.7.3 on 9.12.3, the 9.12.3-upgraded index on main); its own fix passes 7/7 and 4/4.
cross-model review, refute lens real, highAn independent reviewer from another model family (OpenAI GPT-5.6), executing: built its own old-derived index with the real 9.12.3 jars from the shipped 8.11.1 fixture; its test fails 2 of 3 (control passes), also with -da and over 3 iterations; the fix passes, plus TestDirectoryReader 34/34 and TestDirectoryReaderReopen 30/30.
cross-model review, intended lens real, highSame reviewer family, executing: rated the "Expert / best effort" case moderate and rejected it; its own test fails 2/4 in exactly the claimed operations; its real 9.12.3-upgraded index fails; the fix passes.
devil's advocate real, medium-highBuilt and rejected the works-as-designed case; executed SearcherManager and ReaderManager refresh, the newer-commit path and the reopen-by-open workaround on main and 10.5.2.
adjudication confirmed, highRe-ran the reproducer's tests (-ea, -da: 5/7 fail), the real-index test (3/4 fail) and its fix (7/7 pass); advised leading with isCurrent() on an unchanged index and keeping the severity modest.
Follow-up new; security none19 history runs from 8.11.4 to main; a fail-closed security probe (1,500 and 901 calls); a 175-query prior-art search.
this doc's re-runs reproduces; fix validatedGradle on 645d4fe14a20 (assertions on and off), standalone on main and 10.6.0 (-ea, -da); the recommended fix with gradle on the repro, four reader-lifecycle suites, all of lucene/core, TestBasicBackwardsCompatibility and the formatting check.

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

Finding record

LFV-131, snapshot of 2026-11-06

Registry title
A DirectoryReader opened with open(commit, minSupportedMajorVersion, leafSorter) forgets that minimum: isCurrent(), openIfChanged() and SearcherManager.maybeRefresh() throw IndexFormatTooOldException even on an unchanged index
Area
index / DirectoryReader reopen and isCurrent (expert old-index open)
Priority
Low, rank 783: Since 9.0, readers opened with the expert lower-minimum open throw IndexFormatTooOldException on every isCurrent, openIfChanged and maybeRefresh; loud, a reopen works around it, and the known users of that API never refresh.
Impact class
crash
Impact note
Applications that read an index whose segments come from a major below Version.MIN_SUPPORTED_MAJOR through the expert DirectoryReader.open(IndexCommit, int minSupportedMajorVersion, Comparator) (and, by reading, its ExecutorService variant) and then check or refresh that reader get IndexFormatTooOldException from isCurrent(), openIfChanged(reader) and SearcherManager/ReaderManager.maybeRefresh() on every call, even when nothing changed, while the open reader keeps serving its commit (no wrong results, no data loss, no file change); default opens and NRT readers are unaffected; on main the trigger is an 8.x-created index whose segments were all rewritten by 9.x (for example by IndexUpgrader), on 10.x any 8.x index and on 9.x any 7.x index; no scale needed; workaround: close the reader and call open(commit, min, sorter) again.
Summary
You open an index whose segments come from an older Lucene major, which the default open refuses, through the expert DirectoryReader.open(commit, minSupportedMajorVersion, leafSorter) with a lower minimum, and it opens fine. With nothing committed since, you expect isCurrent() to return true and openIfChanged(reader) to return null, so that SearcherManager.maybeRefresh() does nothing. Instead all three throw IndexFormatTooOldException ('Index has segments derived from Lucene version 8.x and is not supported ...'), because the reader does not keep the minimum it was opened with and re-reads the commit with the default Version.MIN_SUPPORTED_MAJOR.
Introduced
c1ae6dc07c9a (LUCENE-9669 'Add an expert API to allow opening indices created < N-1', apache/lucene-solr#2212 by s1monw, approved by jpountz and mikemccand; issue apache/lucene#10708) added the per-reader minimum to the initial open only and did not store it on the reader; the default-minimum re-reads in isCurrent() and doOpenFromCommit() predate it (c4bf601b68f, LUCENE-5969, 2014-10-22) [read: git show, git log -S minSupportedMajorVersion, git blame; exec: commit-level builds, the repro fails 2/2 on c1ae6dc07c9 and does not compile on its parent 426c902bc96, which lacks the API] (2021-01-19 (c1ae6dc07c9, LUCENE-9669))
First affected version
9.0.0 [exec: the synthetic repro fails 2/2 and the real, unmodified 7.7.3 back-compat fixture fails 3/4 on 9.0.0; 8.x and older have no such API: 8.11.4 does not compile the repro, javap of the 8.8.0 and 8.11.4 jars]
Affected versions
every release from 9.0.0 through 10.5.2 (the last release before 10.6.0; 33 release tags), 10.6.0 (by reading) and main ea57360912ed; executed and reproduced [exec]: commit c1ae6dc07c9, 9.0.0, 9.8.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0, 10.5.2 and main (also with -da on c1ae6dc07c9, 9.0.0, 10.5.2 and main); real, unmodified back-compat fixtures [exec, fail]: 7.7.3 on 9.0.0 and 9.12.3, 8.11.4 on 10.0.0 and 10.5.2, and an index the stock 9.12.3 IndexUpgrader produced from the 8.11.4 fixture on main, 10.5.2, 10.4.0 and 10.3.2; the releases in between carry the same two default-minimum calls and no stored minimum [read at every tag]; the gate rewrite #14607 (10.4.0) changed only the message; not affected: 8.x and older, which have no such API [exec 8.11.4; read javap]; not fixed upstream: main, branch_10x, branch_10_6 and branch_9_12 unchanged on 2026-10-26 [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found: no issue, PR, review comment, mailing-list post, JIRA entry, downstream report or web page describes this bug, and main, branch_10x, branch_10_6 and branch_9_12 do not fix it (read 2026-10-26). Searched 2026-10-25 and 2026-10-26: the finder's scripted prior-report search (16 GitHub, 3 commit, 2 downstream, 20 mailing-list and 4 JIRA queries), the independent reproduction's and the intended-behaviour check's read-only searches (the intended-behaviour check: 24 list and JIRA queries, 218 hits), and the follow-up search (2026-10-26, 175 query lines: a scripted prior-report search with 28 GitHub issue/PR, 3 commit, 2 downstream, 32 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA and 6 web queries; 60 GitHub GraphQL searches over apache/lucene, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch, each read to the end; lists.apache.org, JIRA and Stack Overflow searches; the changed-file lists of all 413 open apache/lucene PRs: none changes the two calls). Related, not this bug: #10708 and lucene-solr#2212 (LUCENE-9669, introducing; nobody discussed reopen), #11066 / #214 (LUCENE-10027) and #10546 (LUCENE-9507: leafSorter carried across reopen), #12229 and #14027 (made the expert SegmentInfos overloads public), #14607, #15431 and #15454 (gate rewrite), #16741 (merged 2026-10-15: committers questioned whether the expert minimum-version APIs are still needed; a follow-up that may remove the read-side gate on main was announced, none found), #6993 (LUCENE-5931) and #3659 (LUCENE-2585), other reopen bugs; downstream users of the API that never reopen such readers: Elasticsearch ReadOnlyEngine (source read), OpenSearch#5429 (its earlier ReadOnlyEngine), apache/pinot#19894
Lucene commit tested
main 645d4fe14a20 (2026-10-28, 11.0.0-SNAPSHOT, MIN_SUPPORTED_MAJOR 9) with gradle in fresh scratch worktrees: the one-test repro fails with assertions on and off; with the recommended fix it passes, together with four reader-lifecycle suites, every lucene/core test, TestBasicBackwardsCompatibility and the formatting check (lucene/replicator compiles; its tests are nightly or nested-only and were skipped). The standalone JUnit runner on the same commit and on the released 10.6.0 jars (fails, assertions on and off); the real index that the stock 9.12.3 IndexUpgrader produced from Lucene's 8.11.4 fixture on 645d4fe14a20, stock (3 of 4 fail) and with the fix (4 of 4 pass). The hunt's runs on the introducing commit c1ae6dc07c9, 9.0.0, 9.8.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0, 10.5.2 and main ea57360912ed, including the real 7.7.3 and 8.11.4 fixtures. Read on 2026-10-31: StandardDirectoryReader.java is byte-identical at 645d4fe14a20, branch_11x 603af870375 and upstream main 2f424fd428da (12.0.0-SNAPSHOT), and branch_10_6 7e5aec4370e has the same two calls.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:77-108 (expert open: minimum used once in SegmentInfos.readCommit(directory, segmentFileName, minSupportedMajorVersion), not stored), :477-492 (doOpenNoWriter -> isCurrent; same-commit short-circuit), :496-503 (doOpenFromCommit; default-minimum readCommit at :501), :507-511 (doOpenIfChanged carries subReadersSorter, no minimum), :529-542 (isCurrent; default-minimum readLatestCommit at :537)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:293-297 (readCommit(Directory, String) -> Version.MIN_SUPPORTED_MAJOR), :305-306 (readCommit with a minimum), :529-547 (per-segment gate, IndexFormatTooOldException 'Index has segments derived from Lucene version'), :581-582 (readLatestCommit(Directory) -> Version.MIN_SUPPORTED_MAJOR), :591 (readLatestCommit with a minimum)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:169-193 (expert open(IndexCommit, int, Comparator) javadoc: 'All backwards compatibility aside from the file format is optional and applied on a best effort basis'), :196-225 (ExecutorService variant), :226-229 (openIfChanged javadoc: 'else, return null'), :625-642 (isCurrent javadoc: 'checks if any further commits ... have occurred')
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:45-48, :77-108, :477-505, :507-511, :529-544
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:293-296, :305-319, :523-545, :581-599
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:169-225; lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:194-206; lucene/core/src/java/org/apache/lucene/index/ReaderManager.java:92-94; lucene/core/src/java/org/apache/lucene/util/Version.java:308
Who is affected
  • a Lucene clone release tags: lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java gate on the created major at 9.0.0 :345, 9.12.3 :358, 10.0.0 :341, 10.3.2 :349 ('indexCreatedVersion < minSupportedMajorVersion'); on each segment's minVersion at 10.4.0 :492, 10.6.0 :529; registry rows LFV-152 and LFV-119 (the #14607 change of which indexes open by default)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:169-177, :228-229, :242, :628-629
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:507-511; lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:1204, :1305-1328; lucene/core/src/test/org/apache/lucene/index/TestDirectoryReader.java:1195-1207, :1267-1286; lucene/backward-codecs/src/test/org/apache/lucene/backward_index/TestBasicBackwardsCompatibility.java:856-871; lucene/CHANGES.txt:5015-5020

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

Repro files

About the diagrams

A picture of the reader's life: the expert open with minimum 8, the re-check with the default minimum 9 on the unchanged commit, and the outcome with and without the fix. The values come from the executed runs on the real 9.12.3-upgraded index and the one-test repro.

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.