All findings · Lucene finding · LFV-885 · multiindexmergescheduler-advertised-but-package-private

MultiIndexMergeScheduler is a 10.3.0 New Feature, but the class is package-private, so applications cannot construct it through the public API

Lucene 10.3.0 announces MultiIndexMergeScheduler as "a production multi-tenant merge scheduler" and its javadoc says to create one per index, but the class is declared without public: new MultiIndexMergeScheduler(dir) in application code does not compile. That holds in every release that has it, 10.3.0 to 10.6.0.

Summary

Priority
Low, rank 1236The docs advertise MultiIndexMergeScheduler, but its visibility makes it unusable, which fails loudly at compile time and damages nothing.
Impact
minor
Since
10.3.0 (the class was added package-private by #15015, 2025-08-08)introduced 2025-08-08 · about 1 year
Affected
10.3.0 to 10.6.0, and main
Status
documented
Found by
non-formal: a scan of the release jars for advertised classes nothing outside Lucene can reach, then code reading

AI-assisted page: this finding was first signalled by code reading or testing 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

One missing public hides an advertised class from every package except Lucene's own.

Background: merge schedulers and package-private classes

  1. IndexWriter hands merges to the MergeScheduler set with IndexWriterConfig.setMergeScheduler. The default, ConcurrentMergeScheduler, runs them in background threads, with limits on how many merges and threads run at once.
  2. MultiIndexMergeScheduler (10.3.0, #15015) is a thin per-index wrapper: every instance forwards its merges to one shared, JVM-wide CombinedMergeScheduler, so many indexes share one pool of merge threads.
  3. In Java, a class declared without public is package-private: code in any other package cannot name it or call its constructor, even a public one; only unsupported tricks such as deep reflection get around that, and only on the class path.
Diagram of the bug: Lucene 10.3.0's CHANGES list MultiIndexMergeScheduler under New Features as a production multi-tenant merge scheduler, and its javadoc says to create one instance per index. An application in its own package writes new MultiIndexMergeScheduler(dir) and javac fails with 'MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package', because line 36 declares 'class MultiIndexMergeScheduler' without public. The same code with ConcurrentMergeScheduler, a public class, compiles.
The promise and the code side by side: the 10.3.0 release notes and the javadoc say to create one scheduler per index, but the application's 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.

The finding, step by step

  1. 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.
  2. In your own package you write iwc.setMergeScheduler(new MultiIndexMergeScheduler(dir)). You expect it to compile, as the same line with new ConcurrentMergeScheduler() does.
  3. 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 throws IllegalAccessException.
  4. The cause is one missing word: line 36 says class MultiIndexMergeScheduler, not public 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.

Who is affected

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.

Mechanism (Lucene main 645d4fe14a20)

  • 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) and CombinedMergeScheduler.peekSingleton() (:126-128). Only the test constructor (:48-49, "for unit testing") is package-private on purpose. The nested CombinedMergeScheduler (: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 in package 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 the ConcurrentMergeScheduler page 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.

What an application sees

  • 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\"").
  • javap on the lucene-core jars of 10.3.0 to 10.5.2, 10.6.0 and main prints class org.apache.lucene.index.MultiIndexMergeScheduler (no public) with a public (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", and setAccessible throws InaccessibleObjectException. 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.

Minimal repro

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.

TestMultiIndexMergeSchedulerFromApplication.java (one test class)18 lines
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);
    }
  }
}
Command
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:compileTestJava fails with 2 errors: "MultiIndexMergeScheduler is not public in org.apache.lucene.index; cannot be accessed from outside package".

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails to compile; 10.3.0 to 10.6.0: fail; with `public class`: passes
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
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

Observed output

# 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 @...

Why it looks unintended

  • 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-32 and :41 as 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 nested CombinedMergeScheduler; they discuss how users configure the shared scheduler behind a MultiIndexMergeScheduler, 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 nested CombinedMergeScheduler, in the thread on whether to make it private 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 merge cfb407bb0080; 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.

Suggested fix

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.

Make it public, initialize the shared scheduler, document 3 methods (validated) (recommended)

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. IndexWriter calls initialize() only on the scheduler it was given (IndexWriter.java:960); for this class that is MergeScheduler.initialize (MergeScheduler.java:79), which only stores the info stream; the source comment at MultiIndexMergeScheduler.java:84-86 says "We don't need the initDynamicDefaults() part". The shared scheduler is created with a plain new (MultiIndexMergeScheduler.java:107-113), so maxMergeCount stays -1 (ConcurrentMergeScheduler.java:66, 88), and maybeStall loops while a merge is pending and mergeThreadCount() >= 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 through initialize -> 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 forwarding initialize(), would also close the gap.
  • Why the javadocs. Once the class is public, :lucene:core:renderJavadoc fails with 3 errors, "MultiIndexMergeScheduler.getDirectory (method): javadocs are missing" and the same for getCombinedMergeScheduler and setInfoStream.
  • 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 explicit setMaxMergesAndThreads made earlier is lost with it; on a live singleton an explicit setMaxMergesAndThreads still overrides the defaults. The formula is the code's: the AUTO_DETECT_MERGES_AND_THREADS javadoc (ConcurrentMergeScheduler.java:62-64) says max(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, because MultiIndexMergeScheduler does not override getIntraMergeExecutor, so IndexWriter (IndexWriter.java:3558, 5542) gets MergeScheduler's same-thread executor (MergeScheduler.java:38, 65-67). Alternatives, not run: forward initialize() from MultiIndexMergeScheduler to 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 forward getIntraMergeExecutor too is a separate choice); and return ConcurrentMergeScheduler from getCombinedMergeScheduler(), as the reviewer first proposed (comment 2250080260). Today's return type is package-private, so mims.getCombinedMergeScheduler().setDefaultMaxMergesAndThreads(false) does not compile in application code even with public class ("is defined in an inaccessible class or interface"); only assigning the result to a ConcurrentMergeScheduler variable first does.
  • A public class gets a published javadoc page and becomes API that back-compat reviews cover.

What the checks validated

  • Repro, TestMultiIndexMergeScheduler and 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 a LogDocMergePolicy with merge factor 2, failing if addDocument is 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, forbiddenApisTest and renderJavadoc pass, with the repro's long comment line wrapped as google-java-format wants.

public class alone (validated, not enough by itself)

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.

Stop advertising it

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.

Downstream note

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.

How it was found

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), javap of 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 ConcurrentMergeScheduler control; 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.

Prior reports, history and checks

Prior reports

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

What was searched, when, and what turned up
WhereWhenHowFound
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 search2026-10-28scripted 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 PRsnothing 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 onstill class at line 36; only #15015 and #15105 ever touched the file; nothing new on the class

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails to compile through gradle (assertions on and off) and standalone (-ea, -da)
10.6.0 (4ae946e5a9b9) affectedthe finder's test fails 2 of 2 (-ea, -da)
10.3.0 to 10.5.2 affectedthe 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 absentno such class (ClassNotFoundException on 9.12.3 and 10.2.2)
main + recommended fix not affectedthe repro, TestMultiIndexMergeScheduler and the merging check pass; lint and javadoc pass

Independent checks

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

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, javap, #15015 history) reproduced, documentation defectOwn 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 realApplication-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 accountSaid 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 defectRe-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 reproducesEvery release 10.3.0 to 10.6.0 fails; the class is absent before 10.3.0.
This doc (2026-11-01) reproduced, fix validatedGradle 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 fixedRe-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.

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

Finding record

LFV-885, snapshot of 2026-11-06

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). With public class alone the repro and TestMultiIndexMergeScheduler pass (4 tests, 0 failures, both ways), but a merging check through the "use this one" constructor stalls (shared scheduler maxMergeCount=-1) and renderJavadoc reports 3 missing method javadocs. With the recommended fix (public, default limits for the shared scheduler, the 3 javadocs) the repro, TestMultiIndexMergeScheduler and the merging check pass (5 tests, 0 failures, both ways) and the :lucene:core format, 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 commit 4ae946e5a9b9; 9.12.3 and 10.2.2 have no such class. The 2026-10-28 checks ran on main a9779ef3c193, whose MultiIndexMergeScheduler.java is the same blob; the 2026-10-29 repro minimization and re-verify ran on 645d4fe14a20.

Lucene and upstream sources

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-128
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/module-info.java:39; lucene/CHANGES.txt:1555, 1579, 1581
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestMultiIndexMergeScheduler.java:17
  • a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:464
  • a 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-49
  • a 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-113
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestMultiIndexMergeScheduler.java:47, 121
  • a 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

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and executed probes.

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.