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.
- 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. - A normal open refuses a commit when any segment's
minVersionmajor is belowVersion.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). - The expert
DirectoryReader.open(commit, minSupportedMajorVersion, leafSorter)lowers that minimum for one reader, so such an index can be read (read-only: anIndexWriterstill refuses it).
- 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 recordsminVersion8.11.4. Lucene 11 supports majors 9 and later, so the defaultDirectoryReader.open(dir)refuses it. - Open it with the expert
DirectoryReader.open(commit, 8, null): it opens, with 35 documents, and searches work. - Write nothing, then call
reader.isCurrent(),DirectoryReader.openIfChanged(reader)orSearcherManager.maybeRefresh(). You expecttrue,nulland a no-op: nothing was committed. - Instead all three throw
IndexFormatTooOldException("Index has segments derived from Lucene version 8.x ... found: 8, minimum supported: 9") fromisCurrent(), on every call: the reader re-read its own commit with the default minimum 9, not the 8 it was opened with. - 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.
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.
DirectoryReader.open(commit, minSupportedMajorVersion, leafSorter)(DirectoryReader.java:189-194) callsStandardDirectoryReader.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 withnew StandardDirectoryReader(directory, readers, null, sis, leafSorter, false, false)(:100-101). The reader has no field for the minimum (:45-48): it is dropped here.isCurrent()(:529-544), for a reader without a writer, re-reads the latest commit withSegmentInfos.readLatestCommit(directory)(:537), which isreadLatestCommit(directory, Version.MIN_SUPPORTED_MAJOR)(SegmentInfos.java:581-583).openIfChanged(reader)goes todoOpenNoWriter(null, ...), which callsisCurrent()first (StandardDirectoryReader.java:477-482).SearcherManager.refreshIfNeeded(SearcherManager.java:194-206) andReaderManager.refreshIfNeeded(ReaderManager.java:92-94) callopenIfChanged.- A reopen onto a different commit goes through
doOpenFromCommit(StandardDirectoryReader.java:496-505), which reads it withSegmentInfos.readCommit(directory, segmentFileName)(:501), also the default minimum (SegmentInfos.java:293-296).openIfChanged(reader, newerCommit)reaches it directly;openIfChanged(reader)reaches it only afterisCurrent()(StandardDirectoryReader.java:479-482), which already throws while the latest commit is one the default gate refuses. - The per-segment gate (
SegmentInfos.java:523-545) throwsIndexFormatTooOldExceptionwhen a segment'sminVersionmajor is below the minimum it is given (:529). With the default 9 it rejects the commit the reader opened with 8. openIfChanged(reader, reader.getIndexCommit())does return null: it short-circuits on the segments file name before reading anything (StandardDirectoryReader.java:487-490).- The overload's other parameter survives:
doOpenIfChanged(SegmentInfos, ...)passessubReadersSorter, theleafSorter, to the reopened reader (:507-511, from LUCENE-9507). There is no minimum to pass.
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 itsExecutorServicevariant), and then callisCurrent(),openIfChanged(), or refresh it through aSearcherManager/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 byIndexUpgrader; 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'sminVersion: on 10.4-10.6 the index is one with a segment derived from 8.x (an 8.x-created index whose segments all haveminVersion9 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.internalStandardDirectoryReader.open(Directory, SegmentInfos, ...), given aSegmentInfosread 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
IOExceptionon 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()returnsNO_REFRESH), OpenSearch's earlierReadOnlyEngine(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).
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).
/*
* 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);
}
}
}
}
}
# 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-eaand-da.
isCurrent()"checks if any further commits ... have occurred";openIfChangedreturns "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
leafSortersurvives 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 callingopen, then this method checks if any further commits (seeIndexWriter#commit) have occurred in the directory." The expert overload is one of thoseopenmethods; 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 thanMIN_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: inTestIndexWriterReader.testIndexReaderWriterWithLeafSorter, "Test4" opensDirectoryReader.open(latestCommit, Version.MIN_SUPPORTED_MAJOR, leafSorter)and checks the leaf order afteropenIfChanged(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
isCurrenton a lower-minimum reader:TestBasicBackwardsCompatibility.testFailOpenOldIndexopens and closes (:856-871);TestDirectoryReader.testOpenWithInvalidMinCompatVersion(:1195-1207) andtestOpenCommitWithMinVersionAndExecutor(:1267-1286) open current indexes and close them. - History. Before
c1ae6dc07c9every open used the same default gate as the two re-reads (last rewritten byc4bf601b68f, 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,isCurrentor 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.
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.
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
intper reader. No change for readers opened with the default minimum (the field is thenVersion.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 plusTestDirectoryReader,TestDirectoryReaderReopen,TestIndexWriterReader(incl. the leaf-sorter reopen test) andTestSearcherManager, 5 suites, 101 tests, 0 failures, 1 skipped; the test with assertions off passes. - With the fix, every
lucene/coretest: 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 publicopen(Directory, SegmentInfos, ...), compiles, but its 15 tests are nightly or nested-only and were skipped. :lucene:core:checkGoogleJavaFormatpasses 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),isCurrentafter 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.
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.
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.
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.
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
minVersionvalues 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 "
isCurrentandopenIfChangedon a reader opened with a lower minimum" mismatched on all 21 structurally valid tuples whose segments'minVersionmajor is belowMIN_SUPPORTED_MAJOR. - Those reopen operations were added to the harness from code reading of
StandardDirectoryReaderwhile 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (GitHub issues/PRs incl. the JIRA archive, commits, downstream, dev@ / java-user@ / issues@ / commits@, JIRA, web) | 2026-10-26 | 28 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-9669 | no 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 GitHub | 2026-10-26 | 60 queries, each read to the end; every item that mentions the minimum-version API read in full | no report; related design items only |
| Open apache/lucene PRs | 2026-10-26; re-checked 2026-10-31 | changed-file lists of all 413 open PRs; on 2026-10-31, PRs touching StandardDirectoryReader created since 2026-10-20 | none changes the two calls; no new PR |
| Design discussions | 2026-10-26 | the 2021 dev@ RFC thread (11 mails), lucene-solr#2212 (all review comments), #10708, LUCENE-10027 (#11066 / #214), #14607 (40 comments), #16741 | nobody discusses reopen, isCurrent or refresh for these readers |
| GitHub issue/PR search, apache/lucene | 2026-10-31 | minSupportedMajorVersion; isCurrent IndexFormatTooOldException; SearcherManager IndexFormatTooOldException; openIfChanged minimum version; MIN_SUPPORTED_MAJOR reopen; readLatestCommit isCurrent; the #16741 author's PRs since 2026-10-15 | only the known #16741, #14607, #214, #11066, #10546; 0 hits otherwise; no follow-up PR found |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 (11.0.0-SNAPSHOT) | affected | gradle: 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 | affected | the 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 | affected | synthetic 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 | affected | synthetic 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 commit | fails 2/2 with -ea and -da; the parent has no such API (compile error) |
| 8.11.4 and older | not affected | no 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 | read | same two default-minimum calls (byte-identical file on branch_11x and main), read 2026-10-31 |
fix (645d4fe14a20 + fix.diff) | not affected | the repro and the real-index test pass; all 8,898 lucene/core tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 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, high | An 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, high | Same 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-high | Built 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, high | Re-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 none | 19 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 validated | Gradle 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. |
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 509 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,323 words
- 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_MAJOR9) 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, everylucene/coretest,TestBasicBackwardsCompatibilityand the formatting check (lucene/replicatorcompiles; 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.3IndexUpgraderproduced from Lucene's 8.11.4 fixture on645d4fe14a20, stock (3 of 4 fail) and with the fix (4 of 4 pass). The hunt's runs on the introducing commitc1ae6dc07c9, 9.0.0, 9.8.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0, 10.5.2 and mainea57360912ed, including the real 7.7.3 and 8.11.4 fixtures. Read on 2026-10-31:StandardDirectoryReader.javais byte-identical at645d4fe14a20,branch_11x603af870375and upstream main2f424fd428da(12.0.0-SNAPSHOT), andbranch_10_67e5aec4370ehas the same two calls.
- 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-544a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:293-296, :305-319, :523-545, :581-599a 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-629a 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
- TestReopenBelowMinSupportedMajor.java (JUnit test):
repro/LFV-131/src/org/apache/lucene/index/TestReopenBelowMinSupportedMajor.java(51 lines)
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.
assets/LFV-131/bug.png(509 KB)
