One missing public hides an advertised class from every package except Lucene's own.
IndexWriterhands merges to theMergeSchedulerset withIndexWriterConfig.setMergeScheduler. The default,ConcurrentMergeScheduler, runs them in background threads, with limits on how many merges and threads run at once.MultiIndexMergeScheduler(10.3.0, #15015) is a thin per-index wrapper: every instance forwards its merges to one shared, JVM-wideCombinedMergeScheduler, so many indexes share one pool of merge threads.- In Java, a class declared without
publicis package-private: code in any other package cannot name it or call its constructor, even apublicone; only unsupported tricks such as deep reflection get around that, and only on the class path.
new MultiIndexMergeScheduler(dir) stops at javac because line 36 declares the class without public, while the same line with ConcurrentMergeScheduler compiles. Select the still to open it at full size. Generated with AI, checked against the code.- You run many indexes in one JVM and want them to share one pool of merge threads. The 10.3.0 release notes list "MultiIndexMergeScheduler: a production multi-tenant merge scheduler", and its javadoc says: create one instance per index and use it like a
ConcurrentMergeScheduler. - In your own package you write
iwc.setMergeScheduler(new MultiIndexMergeScheduler(dir)). You expect it to compile, as the same line withnew ConcurrentMergeScheduler()does. - Instead javac stops with 2 errors, at the import and at the
new: "MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package". Plain reflection on the public constructor throwsIllegalAccessException. - The cause is one missing word: line 36 says
class MultiIndexMergeScheduler, notpublic class, although its constructor ("use this one") and its getters are public. Lucene's only test of it sits in the same package, so Lucene's own build never saw the error.
Only applications that opt in to this experimental scheduler, typically ones hosting many indexes per JVM, and that follow the release notes or javadoc: every attempt through the public API fails at compile time. Nothing breaks at runtime, and the default scheduler is unaffected. GitHub code search found no user outside Lucene's own test.
Mechanism, terms and numbers548 words
Lucene 10.3.0 lists MultiIndexMergeScheduler under New Features as "a production multi-tenant merge scheduler", and the release announcement advertises it. Its javadoc tells users to create one instance per index and use it like a lone ConcurrentMergeScheduler, and its public constructor is "The main MultiIndexMergeScheduler constructor -- use this one". But the class itself is declared class MultiIndexMergeScheduler extends MergeScheduler, without public, so application code in any other package cannot import it or call that constructor through the public API: javac reports "MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package". It has been so in every release that has the class (10.3.0 to 10.6.0) and on main. It is a documentation/API-visibility mismatch, not a runtime defect: the failure is a loud compile error, the feature is opt-in and experimental, and nothing returns wrong results or damages an index. Adding public alone is not enough, because the shared scheduler behind every instance is never given its merge and thread limits, so the documented usage would then stall at the first merge.
MultiIndexMergeScheduler.java:36:class MultiIndexMergeScheduler extends MergeScheduler {, no access modifier. The package is exported (module-info.java:39,exports org.apache.lucene.index;), but Java still hides a package-private type from every other package.- The members are written as public API: the "use this one" constructor (
:41-42),getDirectory()(:55-57),getCombinedMergeScheduler()(:59-61),setInfoStream(InfoStream)(:87-89) andCombinedMergeScheduler.peekSingleton()(:126-128). Only the test constructor (:48-49, "for unit testing") is package-private on purpose. The nestedCombinedMergeScheduler(:100) is package-private too, although the public getter returns it. - The javadoc (
:30-32): "The multi-tenant merge scheduling can be used easily by creating a MultiIndexMergeScheduler instance for each index and then using each instance normally, the same way you would use a lone ConcurrentMergeScheduler." - The only test,
TestMultiIndexMergeScheduler, is inpackage org.apache.lucene.index;(line 17), so it compiles and the visibility gap stays invisible to Lucene's build. It also configures the shared scheduler by hand (:47,:121), so it never meets the first-merge stall either. - Lucene renders javadoc with
-protected(RenderJavadocPlugin.java:464), so the class has no page in the published API docs (the 10.3.0 page returns 404 while theConcurrentMergeSchedulerpage returns 200, checked 2026-11-01); the CHANGES entry (lucene/CHANGES.txt:1581, under "Lucene 10.3.0" / "New Features", lines 1555 and 1579) and the release announcement are the user-visible promise.
- javac: 2 errors, "MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package", at the import and at
new MultiIndexMergeScheduler(dir). - Plain reflection through the public constructor:
IllegalAccessException("cannot access a member of class org.apache.lucene.index.MultiIndexMergeScheduler with modifiers \"public\""). javapon the lucene-core jars of 10.3.0 to 10.5.2, 10.6.0 and main printsclass org.apache.lucene.index.MultiIndexMergeScheduler(nopublic) with apublic(Directory)constructor.- Precision: two unsupported routes construct it, both only on the class path: code an application places inside
org.apache.lucene.index(a split package), and deep reflection (setAccessible(true)on the public constructor). Both fail on the module path, because lucene-core is a named module that owns the package and does not open it: javac reports "package exists in another module", andsetAccessiblethrowsInaccessibleObjectException. So it cannot be used through supported means.
Exposure and likelihood123 words
- Who is hit. Anyone who wants what #15015 delivers, multi-tenant merge scheduling (many indexes in one JVM sharing one pool of merge threads; the original request is #13883, "A multi-tenant ConcurrentMergeScheduler"), and writes the code the javadoc describes.
- Opt-in. The default merge scheduler is
ConcurrentMergeScheduler(LiveIndexWriterConfig.java:137), and the class is@lucene.experimental(MultiIndexMergeScheduler.java:34). - Scale. Unusable through supported means in all 8 releases that ship it, 10.3.0 (2025-09-13) to 10.6.0 (2026-10-29), and on main. No runtime harm: no wrong results, hang, data loss or index damage.
- How likely. Every developer who tries it hits it at once, but the class has no published javadoc page and GitHub code search finds no user outside Lucene's own test, so few have realistically tried.
One JUnit test in package org.example, standing for application code: it creates the scheduler the way the javadoc says and passes it to IndexWriterConfig. No index is opened, nothing random. On stock Lucene it does not compile; with public class it passes.
package org.example;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.MergeScheduler;
import org.apache.lucene.index.MultiIndexMergeScheduler;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMultiIndexMergeSchedulerFromApplication extends LuceneTestCase {
public void testCreateOnePerIndex() throws Exception {
// Expected: compiles, as with ConcurrentMergeScheduler; actual: javac "MultiIndexMergeScheduler is not public in org.apache.lucene.index".
try (Directory dir = newDirectory();
MergeScheduler scheduler = new MultiIndexMergeScheduler(dir)) {
new IndexWriterConfig().setMergeScheduler(scheduler);
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.example.TestMultiIndexMergeSchedulerFromApplication -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
- The test compiles and passes, as the same code with
new ConcurrentMergeScheduler()does. - Observed
:lucene:core:compileTestJavafails with 2 errors: "MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package".
More files, environment, full output346 words
One test class, one method, deterministic, single thread. It uses Lucene's public API plus Lucene's test framework (LuceneTestCase, newDirectory()), so it is a test in Lucene's core test sources, not a standalone application; standalone application code fails the same way, on the class path and as a named module. The failure is the compile error itself, so there is no runtime assertion; the result does not depend on the seed or on assertions (compilation fails before any test runs).
Placing the file under lucene/core/src/test/org/example/ lets gradle compile it with Lucene's own test setup while keeping it outside org.apache.lucene.index. google-java-format (./gradlew tidy) wraps its long comment line into two.
Regression test: this same test. It fails to compile on stock main and passes with the fix. If committers choose to stop advertising the class instead, no test applies.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails to compile; 10.3.0 to 10.6.0: fail; with `public class`: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run) |
mkdir -p $LUCENE/lucene/core/src/test/org/example
cp $REPRO/TestMultiIndexMergeSchedulerFromApplication.java $LUCENE/lucene/core/src/test/org/example/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.example.TestMultiIndexMergeSchedulerFromApplication -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (identical with false)
> Task :lucene:core:compileTestJava
$LUCENE/lucene/core/src/test/org/example/TestMultiIndexMergeSchedulerFromApplication.java:5: error: MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package
import org.apache.lucene.index.MultiIndexMergeScheduler;
^
$LUCENE/lucene/core/src/test/org/example/TestMultiIndexMergeSchedulerFromApplication.java:14: error: MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package
MergeScheduler scheduler = new MultiIndexMergeScheduler(dir)) {
^
Note: Some input files use or override a deprecated API.
Note: Recompile with -Xlint:deprecation for details.
2 errors
> Task :lucene:core:compileTestJava FAILED
BUILD FAILED
# with the recommended fix (and with `public class` alone): 1 test, 0 failures, both ways
# plain reflection through the public constructor (the finder's and the independent reproduction's tests, stock main):
java.lang.IllegalAccessException: class ... cannot access a member of class org.apache.lucene.index.MultiIndexMergeScheduler with modifiers "public"
# setAccessible(true) first: constructs it on the class path; with lucene-core on the module path:
java.lang.reflect.InaccessibleObjectException: Unable to make public org.apache.lucene.index.MultiIndexMergeScheduler(org.apache.lucene.store.Directory) accessible: module org.apache.lucene.core does not "opens org.apache.lucene.index" to unnamed module @...- The 10.3.0 CHANGES list it under New Features as "a production multi-tenant merge scheduler", and its javadoc says to create one per index.
- Its "use this one" constructor and getters are
public; #15015 narrowed only the test constructor, and its review discusses what users will call. - Its only test shares its package: the blind spot open issue #10750 describes.
The full argument297 words
- The promise.
lucene/CHANGES.txt:1581(10.3.0, New Features): "GITHUB#15015: MultiIndexMergeScheduler: a production multi-tenant merge scheduler". The 10.3.0 announcement on dev@ and java-user@: "Adds a MultiIndexMergeScheduler – a multi-tenant wrapper that allows sharing a common merge scheduler across multiple instances". Javadoc:30-32and:41as quoted above. - Deliberate narrowing went only to the second constructor. The reviewer (vigyasharma) wrote: "make it pkg private for testing (not exactly safe but makes the intent explicit)" (review comment 2262089375), and PR commit
7a6ee719bb("package private alternate constructor") did exactly that; the main constructor stayed public. - The review assumes users reach the class (indirect evidence). The quotes below are all replies in one review thread, rooted at vigyasharma's "Should we make this class
private static?" (2248892958) on the nestedCombinedMergeScheduler; they discuss how users configure the shared scheduler behind aMultiIndexMergeScheduler, which presumes users hold one, and none is about the top-level modifier. vigyasharma: "users get access to the configuration setters" (2250080260), "I think it is okay to let users directly work with public APIs for the underlying CMS" (2253329820), "It's fine to keep it public too. We probably need it to let people create index groups that share a CMS?" (2257747066, about the nestedCombinedMergeScheduler, in the thread on whether to make itprivate static). The author (corecursion): "the user needs access to the shared CMS for calling configuration functions" (2248960412). No comment asks to hide the top-level class. - History. Package-private from the PR's first commit
ad8cf7b6aa(2025-07-30) through all 17 PR commits and the mergecfb407bb0080; the declaration was never edited since. - Precedent. #10750 (LUCENE-9711, open), "gradle precommit no longer catches accidental package-private APIs?": its example was a new API "accidentally package private, missing its public modifier", hidden because its tests live in the same package.
The devil's-advocate check209 words
The strongest case for "not a bug" (rated moderate by the cross-model reviews): a package-private type is outside the public API by definition; its javadoc is not even published; it is @lucene.experimental; and made public as written, it would stall at the first merge, so maybe it was kept hidden until ready.
Why it fails: CHANGES and the release announcement, which are published, present it as a usable "production" feature, so the published promise and the code disagree whatever the intent. Nothing in the 17 PR commits or the review records a decision to hide the class, and the review discusses what users will call. @lucene.experimental renders as "WARNING: This API is experimental and might change in incompatible ways in the next release." (RenderJavadocPlugin.java:502), a stability warning for public API, not a visibility marker. The stall argues about how to fix it, not whether the mismatch is intended. The cross-model reviews said "not real" with low confidence; neither could run code. The works-as-designed review called its own verdict procedural and agreed on the root cause; the refute review's rested on its inability to run code and on the point that a package-private type is not public API, the case answered here. Neither disputed the root cause.
Recommended Make it public, initialize the shared scheduler, document 3 methods (validated)
If the feature stays: make the class public, give the shared scheduler its default limits in acquireSingleton() (setDefaultMaxMergesAndThreads(false)) and add javadoc to its 3 public methods. public alone stalls the documented usage at the first merge. Validated: repro, TestMultiIndexMergeScheduler, a merging check, lint. Stopping the advertisement instead is the other option (not run).
All options and trade-offs3 options · 1,086 words
Three variants were run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, --offline, seed DEADBEEF): public class alone; plus default limits for the shared scheduler; plus javadoc for the three public methods, which the build's missing-javadoc check demands once the class is public. Removing or rewording the advertisement was not run. Which direction to take is the committers' call.
MultiIndexMergeScheduler.java +10/-1: public on line 36; default merge and thread limits when the JVM-wide CombinedMergeScheduler is created; javadoc on getDirectory, getCombinedMergeScheduler and setInfoStream (the wording is a suggestion).
@@ -33,7 +33,7 @@ import org.apache.lucene.util.InfoStream;
*
* @lucene.experimental
*/
-class MultiIndexMergeScheduler extends MergeScheduler {
+public class MultiIndexMergeScheduler extends MergeScheduler {
private final Directory directory;
private final CombinedMergeScheduler combinedMergeScheduler;
private final boolean manageSingleton;
@@ -52,10 +52,15 @@ class MultiIndexMergeScheduler extends MergeScheduler {
this.manageSingleton = false;
}
+ /** Returns the index directory whose merges this scheduler forwards to the shared scheduler. */
public Directory getDirectory() {
return directory;
}
+ /**
+ * Returns the shared ConcurrentMergeScheduler that runs the merges of every index, for example to
+ * change its merge and thread limits.
+ */
public CombinedMergeScheduler getCombinedMergeScheduler() {
return combinedMergeScheduler;
}
@@ -84,6 +89,7 @@ class MultiIndexMergeScheduler extends MergeScheduler {
// We created this method because we cannot easily override the initialize() method
// in ConcurrentMergeScheduler. We don't need the initDynamicDefaults() part in the
// initialize() method, and only need the setInfoStream().
+ /** Sets the InfoStream of the shared scheduler. */
public void setInfoStream(InfoStream infoStream) {
this.combinedMergeScheduler.setInfoStream(infoStream);
}
@@ -107,6 +113,9 @@ class MultiIndexMergeScheduler extends MergeScheduler {
private static synchronized CombinedMergeScheduler acquireSingleton() {
if (singleton == null) {
singleton = new CombinedMergeScheduler();
+ // IndexWriter never calls initialize() on the shared scheduler, so resolve the
+ // AUTO_DETECT merge and thread limits here, as initialize() does for a lone CMS.
+ singleton.setDefaultMaxMergesAndThreads(false);
}
singletonRefCount++;
return singleton;Trade-offs
- Why the second hunk.
IndexWritercallsinitialize()only on the scheduler it was given (IndexWriter.java:960); for this class that isMergeScheduler.initialize(MergeScheduler.java:79), which only stores the info stream; the source comment atMultiIndexMergeScheduler.java:84-86says "We don't need the initDynamicDefaults() part". The shared scheduler is created with a plainnew(MultiIndexMergeScheduler.java:107-113), somaxMergeCountstays -1 (ConcurrentMergeScheduler.java:66, 88), andmaybeStallloops while a merge is pending andmergeThreadCount() >= maxMergeCount(ConcurrentMergeScheduler.java:611), which 0 >= -1 always satisfies; the indexing thread waits until a merge is no longer pending or someone changes the shared scheduler's limits. A lone CMS avoids that throughinitialize->initDynamicDefaults(ConcurrentMergeScheduler.java:533-535,:442-444). Lucene's test configures the shared scheduler by hand (TestMultiIndexMergeScheduler.java:47, 121), and the #15015 author noted in review that it needs that setup (corecursion, comment 2248960412). The review settled on that hand configuration through the getter (2253329820); corecursion also asked whether the shared scheduler "should come with reasonable defaults that are usable for most people" (2253039480), which is what this hunk does. It is one reasonable design: documenting the hand setup, or forwardinginitialize(), would also close the gap. - Why the javadocs. Once the class is public,
:lucene:core:renderJavadocfails with 3 errors, "MultiIndexMergeScheduler.getDirectory (method): javadocs are missing" and the same forgetCombinedMergeSchedulerandsetInfoStream. setDefaultMaxMergesAndThreads(false)picks the SSD defaults once per shared scheduler (maxThreadCount = max(1, cores / 2),maxMergeCount = maxThreadCount + 5,ConcurrentMergeScheduler.java:166-192); the singleton is dropped when the last instance closes (MultiIndexMergeScheduler.java:115-124) and the next one gets the defaults again, so an explicitsetMaxMergesAndThreadsmade earlier is lost with it; on a live singleton an explicitsetMaxMergesAndThreadsstill overrides the defaults. The formula is the code's: theAUTO_DETECT_MERGES_AND_THREADSjavadoc (ConcurrentMergeScheduler.java:62-64) saysmax(1, min(4, cpuCoreCount/2)), but the code (:190) has no cap of 4.- This hunk does not create the shared scheduler's intra-merge executor, which
initialize()also does (ConcurrentMergeScheduler.java:536-538); that looks harmless, becauseMultiIndexMergeSchedulerdoes not overridegetIntraMergeExecutor, soIndexWriter(IndexWriter.java:3558, 5542) getsMergeScheduler's same-thread executor (MergeScheduler.java:38, 65-67). Alternatives, not run: forwardinitialize()fromMultiIndexMergeSchedulerto the shared scheduler (same package, so it can override the package-private method; it would also pass on IndexWriter's info stream and create that executor; whether to forwardgetIntraMergeExecutortoo is a separate choice); and returnConcurrentMergeSchedulerfromgetCombinedMergeScheduler(), as the reviewer first proposed (comment 2250080260). Today's return type is package-private, somims.getCombinedMergeScheduler().setDefaultMaxMergesAndThreads(false)does not compile in application code even withpublic class("is defined in an inaccessible class or interface"); only assigning the result to aConcurrentMergeSchedulervariable first does. - A public class gets a published javadoc page and becomes API that back-compat reviews cover.
What the checks validated
- Repro,
TestMultiIndexMergeSchedulerand the merging check, assertions on and off: 3 suites, 5 tests, 0 failures each way. - Merging check (not the repro): an earlier same-package test that indexes 20 documents through the "use this one" constructor with
maxBufferedDocs(2)and aLogDocMergePolicywith merge factor 2, failing ifaddDocumentis stuck for 10 s: passes (0.78 s in the assertions-off run; the assertions-on log does not list its time; 0.58 s without the javadocs, assertions on). - Lint:
:lucene:core:checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTestandrenderJavadocpass, with the repro's long comment line wrapped as google-java-format wants.
The one-keyword change on line 36.
Trade-offs
The repro compiles, but the documented usage then stalls: the merging check fails with "addDocument stalled for 10 s in the merge scheduler; shared scheduler was CombinedMergeScheduler: maxThreadCount=-1, maxMergeCount=-1". An application could avoid it only by an undocumented step (assign getCombinedMergeScheduler() to a ConcurrentMergeScheduler variable, then call setDefaultMaxMergesAndThreads(false)). renderJavadoc also fails on the 3 undocumented public methods.
What the checks validated
Repro and TestMultiIndexMergeScheduler: 4 tests, 0 failures, assertions on and off. Merging check: fails (stall, as above). Lint, run with the shared-defaults change added: renderJavadoc fails (3 missing javadocs); format, ECJ and forbidden-apis pass.
Remove the class and its test, or keep it package-private and reword the javadoc so it no longer tells users to create instances, with a CHANGES note that the 10.3.0 feature is not available through the public API.
Trade-offs
Drops an announced feature that, as far as GitHub code search shows, nobody outside Lucene uses. The released 10.3.0 CHANGES text cannot be changed. Others are removing unused classes right now (#16873 merged on main, #16862 and #16880 open), so committers may prefer this. Not run.
Search servers that host many indexes per JVM, the natural users of a multi-tenant scheduler, cannot adopt it either; GitHub searches found no reference to it in Elasticsearch, OpenSearch or Solr.
Not a formal find. A scan of the release jars for non-public classes that nothing outside their own tests uses, proposed by an independent reviewer from another model family (OpenAI GPT-5.6), flagged it; a finder confirmed it by reading the class, CHANGES and the #15015 history and by compiling against it from another package.
The method in full148 words
Method: non-formal. A probe ("advertised fix or feature unreachable from the public API") scanned 37 release jars and 3,951 classes, found 7 non-public classes that nothing outside their own tests references, and 2 of them advertised. A hunt unit (a sweep of never-named public API in core and queries) took this one.
- The finder decided it by code reading (the class modifier, the javadoc, CHANGES 10.3.0),
javapof the 10.3.0 and 10.5.2 jars and the #15015 history, then a JUnit test from another package (2 of 2 fail). - The independent reproduction wrote an application-side test that compiles application source with javac, with a
ConcurrentMergeSchedulercontrol; the intended-behaviour check argued the works-as-designed side and ran class-path, module and split-package probes; an adjudication confirmed it (high) as a documentation/API-visibility mismatch. - 2026-10-29 the repro was reduced to one public-API test; 2026-11-01 this doc re-ran it through gradle and validated the fix.
No public report of the visibility found (2026-10-28, 74 distinct queries over GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, downstream trackers, the web; refreshed 2026-11-01). The class name matches only #15015 (the feature) and test-hang items.
Searches2 searches
No public report, discussion or fix of the class being package-private, or of applications being unable to use it, was found in the recorded searches. The #15015 review never raises the top-level modifier.
Related, not this bug: #15015 (adds the class); #13883 (the feature request, open); #15060, #15091, #15098 (test hangs); #10750 (LUCENE-9711, the general precommit gap, open); #16889 (open, a different ConcurrentMergeScheduler thread-count bug). Others file unused-class removals often (#16873 merged, #16862, #16880).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr), dev@/java-user@/issues@/commits@ lists, the web, GitHub code search | 2026-10-28 | scripted prior-report search, 51 queries, plus 23 manual (74 distinct; a 75th entry summarizes the 6 web queries): class name, package-private, "not public", CombinedMergeScheduler, multi-tenant merge; #15015 commits, review comments and reviews read in full; the file lists of 207 open PRs | nothing about visibility; the class name matches #15015, #15060, #15098 and the 10.3.0 announcement |
| apache/lucene upstream main, issues and PRs (REST API; a GitHub search later the same day, by the fact check, found only #15015, #15060 and #15098) | 2026-11-01 (this doc) | the file on main f815e679bf6c and its commit list; titles of the newest issues and PRs, #16861 to #16959 (created 2026-10-17 to 2026-11-01); the changed files of the 22 PRs from #16926 on | still class at line 36; only #15015 and #15105 ever touched the file; nothing new on the class |
Born package-private: cfb407bb0080 (#15015, 2025-08-08; backport 79a9ddafcb79) added the class without public, and no commit has changed the declaration since. Every release that has it, 10.3.0 to 10.6.0, fails; 9.x and 10.0.0 to 10.2.2 have no such class.
Versions tested5 rows
The class came with #15015 ("MultiIndexMergeScheduler: a production multi-tenant merge scheduler", by Shawn Yarbrough (corecursion), merged by vigyasharma). All 17 PR commits declare it without public, starting with ad8cf7b6aa (2025-07-30); git log -S "class MultiIndexMergeScheduler" finds only the merge and its backport. The only later change, ba7f659b1d81 (#15105, 2025-08-21), is an unused-variable cleanup. All eight release tags carry the same file (blob 84b88aafbc97); main's blob 381b11b8594e differs only by that cleanup.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails to compile through gradle (assertions on and off) and standalone (-ea, -da) |
10.6.0 (4ae946e5a9b9) | affected | the finder's test fails 2 of 2 (-ea, -da) |
| 10.3.0 to 10.5.2 | affected | the finder's test fails 2 of 2 on all 7 releases; the independent reproduction's application test fails on 10.3.0 and 10.5.2; javap: no public |
| 9.x, 10.0.0 to 10.2.2 | class absent | no such class (ClassNotFoundException on 9.12.3 and 10.2.2) |
| main + recommended fix | not affected | the repro, TestMultiIndexMergeScheduler and the merging check pass; lint and javadoc pass |
Reproduced by the finder, an independent reproducer, a devil's advocate, the adjudication, a re-verify on the new main and 10.6.0, this doc's gradle runs and fact checks. Cross-model reviews (OpenAI GPT-5.6, reading only) said "not real" with low confidence, citing their inability to run code and the package-private API boundary; neither disputed the root cause.
Checks and verdicts382 words
Every reproduction ran on stock builds; the fix checks ran with the patched MultiIndexMergeScheduler.java only. The cross-model reviewers could not run code, so their verdicts rest on reading; separate runs re-ran the finder's test (2 of 2 fail).
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, javap, #15015 history) | reproduced, documentation defect | Own JUnit from another package: 2 of 2 fail on main (-ea, -da), 10.3.0, 10.5.2, branch_10_6; public class passes. |
| Cross-model refute review (OpenAI GPT-5.6, reading only) | not real (low) | Could run nothing. Its verdict rested on that and on its point that a package-private type is not public API (moderate), which is why this is classed as a documentation/API mismatch. It did not dispute the root cause. |
| Independent reproducer | real | Application-side test (javac of application source, reflection, ConcurrentMergeScheduler control): 2 of 3 fail on main (-ea, -da), 10.3.0, 10.5.2; control passes; 3 of 3 with public. |
| Cross-model works-as-designed lens (OpenAI GPT-5.6, reading only) | not real (low), procedural by its own account | Said itself it is "a procedural not real ... not a substantive finding that the documentation is correct"; rated the package-private-boundary case moderate; root cause agreed. |
| Intended-behaviour devil's advocate | real (high) | javac probes on the class path and as a named module fail on main and 10.3.0; split-package probe works only on the class path; no record of deliberate hiding. |
| Adjudication | confirmed (high), documentation defect | Re-ran the independent reproduction's test on main (seed C0FFEE01, -ea, -da): 2 of 3 fail, control passes. |
History and re-verify on 645d4fe14a20 and 10.6.0 | still reproduces | Every release 10.3.0 to 10.6.0 fails; the class is absent before 10.3.0. |
| This doc (2026-11-01) | reproduced, fix validated | Gradle in a scratch worktree: stock fails to compile both ways; public class alone passes the repro and TestMultiIndexMergeScheduler but stalls the merging check and fails renderJavadoc; with the recommended fix everything passes. |
| Fact checks (one same-family, one cross-model, OpenAI GPT-5.6; 2026-11-01) | bug confirmed; wording fixed | Re-ran the repro on main, 10.3.0 and 10.6.0, the gradle fix ladder and the access probe, and ran deep reflection (setAccessible works on the class path only). Flagged reflection, review-characterization, citation and caveat wording, fixed in this doc. |
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 440 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,258 words
- Registry title
- MultiIndexMergeScheduler is listed under Lucene 10.3.0 New Features as 'a production multi-tenant merge scheduler' and its javadoc says to create one instance per index, but the class is package-private, so application code cannot compile against it or construct it through supported means
- Area
- core / index merge scheduling (MultiIndexMergeScheduler visibility)
- Priority
- Low, rank 1236: The docs advertise MultiIndexMergeScheduler, but its visibility makes it unusable, which fails loudly at compile time and damages nothing.
- Impact class
- minor
- Impact note
- Hits every application that tries the opt-in 10.3.0 feature as the release notes and the class javadoc describe it (10.3.0 to 10.6.0 and main): the source does not compile ('MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package') and reflective construction throws IllegalAccessException, so the failure is loud and at build time, with no wrong results, hang or index damage, and the advertised feature is simply unusable through supported means.
- Summary
- You read the Lucene 10.3.0 release notes ('MultiIndexMergeScheduler: a production multi-tenant merge scheduler') and the class javadoc ('can be used easily by creating a MultiIndexMergeScheduler instance for each index'), and write iwc.setMergeScheduler(new MultiIndexMergeScheduler(dir)) in your application, using the public constructor the javadoc calls 'The main MultiIndexMergeScheduler constructor -- use this one'. You expect it to compile, as the same line with ConcurrentMergeScheduler does. Instead javac rejects it because the class itself is declared 'class MultiIndexMergeScheduler' without 'public' (MultiIndexMergeScheduler.java:36), and it has been package-private in every release that contains it, so only code inside org.apache.lucene.index (in practice Lucene's own test) can use it.
- Introduced
- cfb407bb0080 (main, 'MultiIndexMergeScheduler: a production multi-tenant merge scheduler (#15015)', PR by corecursion, merged by vigyasharma; branch_10x backport 79a9ddafcb79): the class was born package-private, already in the PR's first commit ad8cf7b6aa (2025-07-30) and in all 17 PR commits, so there is no regression window; PR commit 7a6ee719bb narrowed only the test-only constructor to package-private [read: git blame of MultiIndexMergeScheduler.java:30-42 and of the CHANGES entry, git log -S 'class MultiIndexMergeScheduler' --all (only cfb407bb0080 and its backport), per-commit class declaration of #15015; exec: fails on 10.3.0, class absent on 10.2.2] (2025-08-08 (author date of cfb407bb0080 and of the backport 79a9ddafcb79; PR merged 2025-08-08T20:53:02Z; first PR commit 2025-07-30))
- First affected version
- 10.3.0 [exec] (the introducing release, tag dated 2025-09-13; 10.2.2 and older, including every 9.x, have no such class [exec: ClassNotFoundException on 9.12.3 and 10.2.2; read: git ls-tree on every release tag 9.12.0..10.2.2])
- Affected versions
- 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 and 10.5.2, the branch_10_6 head 4ae946e5a9b9 (10.6.0, released 2026-10-29) and main a9779ef3c193 and 645d4fe14a2. [exec] the finder's JUnit fails 2 of 2 on all of them (-ea; also -da on 10.3.0, 10.5.2 and main); the independent reproduction's application-side JUnit (package example.app, javac of application source plus reflection, ConcurrentMergeScheduler control) fails 2 of 3 on 10.3.0 and 10.5.2 (-ea) and on main (-ea and -da), with the control passing; the intended-behaviour check's javac probe fails on main and 10.3.0 on the class path and from a named application module; javap on every affected lucene-core jar shows 'class org.apache.lucene.index.MultiIndexMergeScheduler' (no public) with a public (Directory) constructor; all 7 10.x releases carry the identical file (blob 84b88aafbc97). Not affected: 9.x and 10.0.0 to 10.2.2 (class absent). [read] Not fixed upstream: origin/main 645d4fe14a2, branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b still declare 'class MultiIndexMergeScheduler' at line 36 (re-checked 2026-10-29; the finder's JUnit fails 2 of 2 on the RC1 4ae946e5a9b jars with -ea and -da [exec]); the file's only later commit is ba7f659b1d81 (#15105, author date 2025-08-21, unused-variable cleanup).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (searched 2026-10-28, 75 recorded queries: 51 scripted (16 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 6 web) and 24 manual (16 GitHub issue, PR and comment searches, including Elasticsearch, OpenSearch and Solr; 1 code search; the file lists of 207 open apache/lucene PRs created since 2025-08-01; 3 GitHub API reads: the file's commit history, the class declaration on main, branch_10x, branch_10_6, branch_10_5 and branch_10_3, and the #15015 commits, review comments, reviews and issue comments; 2 list-archive searches; 1 web batch); the finder's 39-query run and the independent reproduction's, the intended-behaviour check's and the adjudication's searches found nothing either: the only items naming the class are #15015 (the feature), #15060, #15091 and #15098 (test hangs) and the 10.3.0 release announcement; refreshed 2026-10-28: none of the 40 most recent PRs (through #16926, 2026-10-28) and issues (through #16928) is about this class, #16918 'Deprecations slated for removal in 11.0' does not mention it, and upstream main c5ab3e57b9ba still declares 'class MultiIndexMergeScheduler' at line 36)
- Lucene commit tested
- main
645d4fe14a20(2026-11-01), gradle in a scratch worktree: the minimal test fails to compile with assertions on and off (2 javac errors). Withpublic classalone the repro andTestMultiIndexMergeSchedulerpass (4 tests, 0 failures, both ways), but a merging check through the "use this one" constructor stalls (shared scheduler maxMergeCount=-1) andrenderJavadocreports 3 missing method javadocs. With the recommended fix (public, default limits for the shared scheduler, the 3 javadocs) the repro,TestMultiIndexMergeSchedulerand the merging check pass (5 tests, 0 failures, both ways) and the:lucene:coreformat, ECJ, forbidden-apis and javadoc checks pass. Standalone, the finder's test fails on every release 10.3.0 to 10.5.2 and on 10.6.0's release commit4ae946e5a9b9; 9.12.3 and 10.2.2 have no such class. The 2026-10-28 checks ran on maina9779ef3c193, whoseMultiIndexMergeScheduler.javais the same blob; the 2026-10-29 repro minimization and re-verify ran on645d4fe14a20.
- Finding record
lucene/core/src/java/org/apache/lucene/index/MultiIndexMergeScheduler.java:30-32 (class javadoc: 'can be used easily by creating a MultiIndexMergeScheduler instance for each index'), :34 (@lucene.experimental), :36 ('class MultiIndexMergeScheduler extends MergeScheduler', no public), :41-42 (public main constructor, 'use this one'), :48-49 (package-private constructor for unit testing), :55-61 (public getters getDirectory and getCombinedMergeScheduler; the latter returns the package-private nested CombinedMergeScheduler)lucene/CHANGES.txt:1538 ('Lucene 10.3.0'), :1562 ('New Features'), :1564 ('GITHUB#15015: MultiIndexMergeScheduler: a production multi-tenant merge scheduler')
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MultiIndexMergeScheduler.java:23-36, 41-42, 48-49, 55-61, 87-89, 100, 126-128a Lucene main checkout (645d4fe14a20) lucene/core/src/java/module-info.java:39; lucene/CHANGES.txt:1555, 1579, 1581a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestMultiIndexMergeScheduler.java:17a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:464a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestMultiIndexMergeScheduler.java:47, 121
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:137; MultiIndexMergeScheduler.java:34
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:1555, 1579, 1581; lucene/core/src/java/org/apache/lucene/index/MultiIndexMergeScheduler.java:30-32, 41-42, 48-49a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:502
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:66, 84, 88, 166-192, 442-444, 533-535, 609-611; IndexWriter.java:960; MergeScheduler.java:79-81; MultiIndexMergeScheduler.java:55-61, 87-89, 107-113a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestMultiIndexMergeScheduler.java:47, 121a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:62-64, 190, 536-538; MergeScheduler.java:38, 65-67; IndexWriter.java:3558, 5542; MultiIndexMergeScheduler.java:115-124
- History
a Lucene clone release tags releases/lucene/10.3.0 to 10.6.0 (MultiIndexMergeScheduler.java blob 84b88aafbc97, line 36 'class'; 10.6.0 = 4ae946e5a9b9)
- Diagrams
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/MultiIndexMergeScheduler.java:30-36, 41-42; lucene/CHANGES.txt:1579-1581
- TestMultiIndexMergeSchedulerFromApplication.java (one test class):
repro/LFV-885/src/org/example/TestMultiIndexMergeSchedulerFromApplication.java(18 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and executed probes.
assets/LFV-885/bug.png(440 KB)
