All findings · Lucene finding · LFV-589 · hnsw-format-accepts-nonpositive-merge-workers

The HNSW vectors formats accept 0 merge workers with a merge executor, then the first graph-building merge throws and closes the IndexWriter

new Lucene99HnswVectorsFormat(16, 100, 0, executor), that is 0 merge workers plus a merge thread pool, is accepted, but the first merge that builds an HNSW graph then throws ArrayIndexOutOfBoundsException and the IndexWriter closes itself. An opt-in expert setting; every release from 9.9.0 to 10.6.0 and main.

Summary

Priority
Low, rank 687The core HNSW formats accept numMergeWorkers <= 0 together with an executor, and every graph merge then closes the writer, which is loud and needs a degenerate computed setting but sits in core formats, so it heads the accepted-setting crash rows ahead of LFV-166 and LFV-226.
Impact
crash
Since
9.9.0 (#12660, 2023)introduced 2023-10-28 · about 3 years
Affected
9.9.0 to 10.6.0 and main
Status
documented
Found by
code reading of the concurrent HNSW merge, then a public-API test

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

The HNSW vectors formats can merge a segment's vector graph with several threads: you pass a worker count and an ExecutorService to the format's constructor. The constructor checks the other graph settings, but not that the worker count is at least 1.

Background: concurrent HNSW merging

  1. Each segment of a kNN vector field holds an HNSW graph. A merge combines segments, so it builds or joins their graphs into one.
  2. By default one thread does that. Lucene99HnswVectorsFormat(maxConn, beamWidth, numMergeWorkers, mergeExec) lets the merge use numMergeWorkers threads from your own ExecutorService (since 9.9.0).
  3. Since 10.4.0, merges too small to need a graph skip it (tinySegmentsThreshold, default 100); the repro sets it to 0 so two vectors suffice.
Diagram of the path from new Lucene99HnswVectorsFormat(16, 100, 0, executor, 0) to the merge: the constructor checks maxConn, beamWidth and one worker with an executor but not 0; createGraphMerger picks ConcurrentHnswMerger because mergeExec is not null; HnswConcurrentMergeBuilder creates new ConcurrentMergeWorker[0] and reading workers[0] throws ArrayIndexOutOfBoundsException, which closes the IndexWriter.
new Lucene99HnswVectorsFormat(16, 100, 0, executor, 0) on two one-vector segments: the constructor lets the 0 through, the writer picks the concurrent merger because an executor exists, and the merger's worker array has length 0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Configure concurrent HNSW merging with 0 workers and a thread pool: new Lucene99HnswVectorsFormat(16, 100, 0, executor, 0). The last 0 (tinySegmentsThreshold) makes even a one-vector segment get a graph. The constructor accepts it.
  2. Index two documents with one 2-dimensional vector each, committing after each (two segments), and call forceMerge(1).
  3. You expect an IllegalArgumentException from the constructor, as for maxConn 0 or for 1 worker with a pool, or a working merge, which is what 0 workers without a pool gives on 10.2.0 and later.
  4. You get ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 from the merge, and the writer closes itself, losing its uncommitted changes. -1 workers gives NegativeArraySizeException. With the default 4-argument constructor a merge of two 1000-vector segments fails the same way.
  5. Why: with a pool, the writer always picks the concurrent merger, which makes an array of numMergeWorkers workers and reads worker 0. With 0 workers the array is empty.

Who is affected

Only applications that hand-configure concurrent HNSW merging and end up with a count below 1 while passing a thread pool, for example availableProcessors() / 2 on a one-CPU machine. The default codec (1 worker, no pool) is safe. The writer closes at its first graph-building merge and loses uncommitted changes; the last commit stays intact.

Mechanism, terms and numbers572 words

Lucene99HnswVectorsFormat(maxConn, beamWidth, numMergeWorkers, mergeExec) lets an application merge HNSW graphs with several threads from its own ExecutorService. The constructor rejects maxConn and beamWidth outside their range and one worker together with an executor, but stores any numMergeWorkers <= 0. With a non-null executor the writer sends every graph-building merge to ConcurrentHnswMerger without looking at the count, and HnswConcurrentMergeBuilder sizes its worker array by it: for 0 the first read of workers[0] throws ArrayIndexOutOfBoundsException, for -1 the allocation throws NegativeArraySizeException. IndexWriter.merge treats that as a tragic event, so the writer closes and loses its uncommitted changes; every new writer with that setting fails the same way at its first such merge. Lucene104HnswScalarQuantizedVectorsFormat, HnswBitVectorsFormat and, since 10.6.0, the sandbox's DedupHnswVectorsFormat and DedupHnswScalarQuantizedVectorsFormat copy the same partial check. The same 0 without an executor merges serially on 10.2.0 and later. Opt-in and loud: the default codec (1 worker, no executor) is not affected, the last commit stays intact and CheckIndex is clean. Every release from 9.9.0 to 10.6.0 and main.

Terms

  • Vectors format: the codec component that writes a field's kNN vectors and its HNSW graph. Lucene99HnswVectorsFormat is the default one; Lucene104HnswScalarQuantizedVectorsFormat (core), HnswBitVectorsFormat (lucene/codecs) and the sandbox's DedupHnswVectorsFormat and DedupHnswScalarQuantizedVectorsFormat (since 10.6.0) take the same merge arguments.
  • numMergeWorkers, mergeExec: "number of workers (threads) that will be used when doing merge" and the ExecutorService they run on (Lucene99HnswVectorsFormat.java:215-219). The default constructor uses 1 worker and no executor (:120, :177-185).
  • Tragic event: an unexpected exception inside IndexWriter that makes it roll back to the last commit and close itself; later calls throw AlreadyClosedException.

The mechanism (main 645d4fe14a20)

  1. No lower bound. The constructor rejects maxConn and beamWidth outside their range ("must be positive ...", Lucene99HnswVectorsFormat.java:271-284) and numMergeWorkers == 1 && mergeExec != null (:289-292), then stores the count as is (:293). 0 and -1 pass. Lucene104HnswScalarQuantizedVectorsFormat.java:181, HnswBitVectorsFormat.java:125, DedupHnswVectorsFormat.java:185 and DedupHnswScalarQuantizedVectorsFormat.java:201 have the same check; both Dedup formats fail the same way (executed on main and 10.6.0).
  2. The executor decides the merger, not the count. Lucene99HnswVectorsWriter.createGraphMerger returns a ConcurrentHnswMerger with the format's count whenever mergeExec != null (Lucene99HnswVectorsWriter.java:674-676). Only the other path, the merge scheduler's intra-merge executor, is guarded by > 1 (:678); otherwise the merge is serial (:688).
  3. An array of 0 workers. HnswConcurrentMergeBuilder does workers = new ConcurrentMergeWorker[numWorker] (HnswConcurrentMergeBuilder.java:153). For -1 that throws NegativeArraySizeException; for 0 the array is empty.
  4. Worker 0 is read. Joining the segments' graphs goes through runInBatches, whose first statement reads workers[0].batchSize (:374-375): ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0. Without graphs to join, finish() reads workers[0] (:478) and fails the same way; that is the site on 10.4.0-10.6.0.
  5. The writer closes. IndexWriter.merge catches any Throwable and calls tragicEvent(t, "merge") (IndexWriter.java:5109-5113).

The numbers

Two committed segments of one 2-dimensional vector each, tinySegmentsThreshold 0, SerialMergeScheduler, forceMerge(1), on main (the diagram probe and the fact check's probe): (0, executor) throws ArrayIndexOutOfBoundsException, (-1, executor) throws NegativeArraySizeException, and after either the writer is closed (isOpen() == false, the merge exception as its tragic exception); (0, null) and (2, executor) merge fine. With the default 4-argument constructor, two 1000-vector segments fail the same way (the independent reproduction, the diagram probe) and two 20-vector segments build no graph and merge.

Exposure and likelihood302 words
  • Who is exposed. Code that builds one of the five formats with an explicit numMergeWorkers and an ExecutorService, and computes or forwards the count: availableProcessors() / 2 on a one-CPU container gives 0, or a 0 / -1 "auto" default is passed through while an executor is always supplied. On 9.11.0-10.1.x even (0, null) fails, through the merge scheduler's intra-merge executor.
  • Opt-in. The default codec builds new Lucene99HnswVectorsFormat(): 1 worker, no executor (Lucene104Codec.java:123). All five formats are @lucene.experimental.
  • Effect. Loud and immediate: the first graph-building merge throws and the writer closes as a tragic event, losing its uncommitted changes. In the security probe forceMerge(1) threw 31-232 ms after it was called, with 600 committed documents kept and 50 uncommitted ones lost; with ConcurrentMergeScheduler the indexing thread saw the error within 0.6 s of starting to index, as "this writer hit an unrecoverable error" or AlreadyClosedException. Every new writer with that setting fails the same way at its first such merge. The minimal repro commits both documents first, so it shows the crash and the closed writer, not a loss.
  • Not affected. The last commit stays intact and CheckIndex is clean; a valid setting merges the same index afterwards; other writers on the same executor are unaffected; the probe observed no hang, no resource growth and no wrong search results.
  • Scale. None: two one-vector segments with threshold 0, or, with the default constructor (tinySegmentsThreshold 100), any merge large enough to build a graph: a merge of n vectors builds one when n > (int) (100 * ln n), from 648 vectors on (Lucene99HnswVectorsWriter.java:453, :712-722). Two 1000-vector segments fail; a 40-vector merge builds no graph and passes.
  • Likelihood: rare. Nobody sets 0 workers on purpose, and no report was found in the documented searches.

Minimal repro

One JUnit test on the public API: build the format with 0 merge workers and an executor, index two one-vector segments, call forceMerge(1). It passes if the constructor rejects the 0 or if the merge works, and fails on the merge exception. Deterministic; it fails with assertions on and off.

TestHnswZeroMergeWorkers.java (1 test)59 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.codecs.lucene99;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.lucene.codecs.KnnVectorsFormat;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.SerialMergeScheduler;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestHnswZeroMergeWorkers extends LuceneTestCase {

  public void testZeroMergeWorkersWithExecutor() throws Exception {
    try (ExecutorService exec = Executors.newSingleThreadExecutor();
        Directory dir = new ByteBuffersDirectory()) {
      KnnVectorsFormat format;
      try {
        format = new Lucene99HnswVectorsFormat(16, 100, 0, exec, 0);
      } catch (IllegalArgumentException e) {
        return;
      }
      IndexWriterConfig iwc =
          new IndexWriterConfig()
              .setCodec(TestUtil.alwaysKnnVectorsFormat(format))
              .setMergeScheduler(new SerialMergeScheduler());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int i = 0; i < 2; i++) {
          Document doc = new Document();
          doc.add(new KnnFloatVectorField("v", new float[] {i, 1}));
          w.addDocument(doc);
          w.commit();
        }
        // expected: IAE above or a working merge; actual: ArrayIndexOutOfBoundsException
        w.forceMerge(1);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or a later commit where these files are unchanged)
cp $REPRO/TestHnswZeroMergeWorkers.java lucene/core/src/test/org/apache/lucene/codecs/lucene99/
./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene99.TestHnswZeroMergeWorkers -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
The constructor rejects the 0 with an IllegalArgumentException, or forceMerge(1) merges the two segments.
Observed
forceMerge(1) throws ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 at HnswConcurrentMergeBuilder.runInBatches (line 374).

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

More files, environment, full output257 words

One class, one method, in lucene/core (59 lines with the license header). TestUtil.alwaysKnnVectorsFormat makes the format the only vectors format; the public 5-argument constructor with tinySegmentsThreshold 0 makes two one-vector segments enough to build graphs (with the 4-argument constructor the same failure needs a merge large enough to build a graph under the default threshold, for example two 1000-vector segments); SerialMergeScheduler runs the merge on the test thread. It does not assert the closed writer: one symptom suffices.

Before it goes into a PR, two edits are needed or ./gradlew :lucene:core:check fails on the test: rename the unused catch parameter e to _ (ecj lint: "The value of the exception parameter e is not used"), and use new SameThreadExecutorService() instead of Executors.newSingleThreadExecutor(), which Lucene's forbidden-APIs check rejects ("Spawns threads with vague names"). The edited test still fails on stock main and passes with either fix, and the module checks then pass. The suggested regression test is this same test.

Environment

lucenemain 645d4fe14a20: affected; also the 10.6.0, 10.5.2 and 10.4.0 release jars with this test
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

# main 645d4fe14a20, ./gradlew :lucene:core:test --tests ...TestHnswZeroMergeWorkers -Ptests.seed=DEADBEEF -Ptests.asserts=true
# (-Ptests.asserts=false: the same failure); middle stack frames elided
TestHnswZeroMergeWorkers > testZeroMergeWorkersWithExecutor FAILED
    java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:7EC450BD9E747D5B]:0)
        at org.apache.lucene.util.hnsw.HnswConcurrentMergeBuilder.runInBatches(HnswConcurrentMergeBuilder.java:374)
        at org.apache.lucene.util.hnsw.HnswConcurrentMergeBuilder.joinGraphs(HnswConcurrentMergeBuilder.java:286)
        at org.apache.lucene.util.hnsw.HnswConcurrentMergeBuilder.build(HnswConcurrentMergeBuilder.java:243)
        at org.apache.lucene.util.hnsw.IncrementalHnswGraphMerger.merge(IncrementalHnswGraphMerger.java:260)
        at org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsWriter.buildAndWriteGraph(Lucene99HnswVectorsWriter.java:525)
        ...
        at org.apache.lucene.index.IndexWriter.merge(IndexWriter.java:5081)
        ...
        at org.apache.lucene.index.IndexWriter.forceMerge(IndexWriter.java:2229)
        at org.apache.lucene.codecs.lucene99.TestHnswZeroMergeWorkers.testZeroMergeWorkersWithExecutor(TestHnswZeroMergeWorkers.java:55)
:lucene:core:test (FAILURE): 1 test, 1  failure

# 10.4.0, 10.5.2, 10.6.0 release jars: the same exception from HnswConcurrentMergeBuilder.finish (10.6.0: line 244)

Why it looks unintended

  • The javadoc calls it the "number of workers (threads)" for the merge, with no lower bound and no exception.
  • The same constructor rejects maxConn or beamWidth below 1 and 1 worker with a pool; ConcurrentMergeScheduler rejects thread counts below 1.
  • Without a pool the same 0 merges serially (10.2.0+); with one, the writer closes.
The full argument214 words
  • The contract. @param numMergeWorkers number of workers (threads) that will be used when doing merge (Lucene99HnswVectorsFormat.java:215-216, :232-233, :254-255; the same in the other four formats). No range, no @throws; no javadoc, CHANGES or MIGRATE entry gives 0 or a negative count a meaning.
  • Validation is the constructor's style. maxConn and beamWidth "must be positive" (:271-284) and (1, executor) is rejected (:289-292); TestLucene99HnswVectorsFormat.testLimits asserts these (:64-74), and so does the quantized format's (TestLucene104HnswScalarQuantizedVectorsFormat.java:273-295); the bit format's testLimits checks only maxConn and beamWidth (TestHnswBitVectorsFormat.java:117-124).
  • Thread counts below 1 are rejected elsewhere: "maxThreadCount should be at least 1" (ConcurrentMergeScheduler.java:145-146).
  • Inconsistent. (0, null) merges serially on 10.2.0+ (the > 1 guard, Lucene99HnswVectorsWriter.java:678, from #14331); open PR #16871 also reads numMergeWorkers <= 1 as serial.
  • Out of proportion. An accepted constructor argument fails later, at an unrelated merge, as an internal array exception that closes the writer.
  • History. The worker array and the partial check came together in #12660 (9.9.0); no review of #12660, #13124/#13190 or #14331 discusses counts below 1. One upstream test passes 0 with a null executor as a placeholder (backward-codecs TestLucene99HnswQuantizedVectorsFormat.testLimits, five calls that never merge), so do not claim that no test passes 0.
The devil's-advocate check297 words

Strongest case (refute review, the cross-model review, the intended-behaviour check, adjudication): 0 or -1 worker threads is nonsense input to an @lucene.experimental expert API; two committers said the count should not be a user setting at all (benwtrent in a #13124 PR discussion comment, 2024-02-23; mikemccand on luceneutil #507, 2026); and the 5-argument constructor the repro uses is labelled "(This is a Test-Only Constructor)".

Why it fails: the constructors are public, and the 4-argument one, not test-only, fails the same way on a 2000-vector merge; the constructor already validates its neighbouring arguments; the value works without an executor; and the failure closes the writer late instead of raising an argument error at configuration time. If maintainers prefer to remove the knob, that resolves the report too. Verdict: not intended, a small missing-validation defect; no checker before the cross-model fact check rated the intended-behaviour argument better than weak; that check rated it moderate.

No-noise check. The hunt does not claim behaviour that needs absurd parameters and then fails loudly. For noise: 0 or -1 worker threads is nonsensical, and the failure is loud. Why it is still claimed, as a low-priority missing-validation bug only: 0 is an accepted value of this constructor (with a null executor it merges serially on 10.2.0+), so adding an executor turns an accepted value into a crash; a 0 comes from ordinary computed settings such as availableProcessors() / 2 on a one-CPU container; and the failure is late and destructive (at the first graph-building merge, closing the writer and dropping uncommitted documents), not an argument error at configuration time where the neighbouring arguments are checked. The cross-model fact check (OpenAI GPT-5.6) judged it "claim-low, not noise", if filed as a narrow missing-validation hardening bug, not as corruption, security or broad availability impact.

Suggested fix

Recommended A: reject a count below 1 when an executor is passed

Reject a count below 1 when an executor is passed, in all five formats' constructors (5 lines each). The repro then passes, and so do the format, HNSW and sandbox Dedup test suites. Alternative: merge serially in createGraphMerger when the count is not above 1 (one line).

if (numMergeWorkers < 1 && mergeExec != null) {
  throw new IllegalArgumentException(
      "numMergeWorkers must be at least 1 when a merge executor is passed; numMergeWorkers="
          + numMergeWorkers);
}
if (numMergeWorkers == 1 && mergeExec != null) { // existing check, unchanged
All options and trade-offs4 options · 607 words

All options were run through gradle on a scratch worktree of 645d4fe14a20 (seed DEADBEEF) unless marked. The adjudication advised A or C over B.

A: reject a count below 1 when an executor is passed (validated by the checks) (recommended)

+5 lines in each of Lucene99HnswVectorsFormat, Lucene104HnswScalarQuantizedVectorsFormat, HnswBitVectorsFormat and the sandbox's DedupHnswVectorsFormat and DedupHnswScalarQuantizedVectorsFormat, just before the existing (1, executor) check; together they say "with an executor, use at least 2 workers". Keeps (0, null) and (-1, null), which merge serially today on 10.2.0+, and touches no upstream test. Applied to only the first three, it would leave the two Dedup formats broken.

if (numMergeWorkers < 1 && mergeExec != null) {
  throw new IllegalArgumentException(
      "numMergeWorkers must be at least 1 when a merge executor is passed; numMergeWorkers="
          + numMergeWorkers);
}
if (numMergeWorkers == 1 && mergeExec != null) { // existing check, unchanged

Trade-offs

  • An application that passes (0, executor) today and never builds a graph in a merge would now fail when it builds its codec.
  • The backward-codecs formats share the partial check, but their production fieldsWriter throws UnsupportedOperationException, so they need no change; writable subclasses of them exist only in backward-codecs tests.
  • One combined check mergeExec != null && numMergeWorkers <= 1 would also work but changes the existing message. The stale javadoc (option D) fits the same change.

What the checks validated

  • Three-format version: the repro passes with assertions on and off; 534 tests in 27 suites pass (the package wildcards org.apache.lucene.codecs.lucene99.*, org.apache.lucene.codecs.lucene104.*, org.apache.lucene.util.hnsw.*, TestKnnGraph; org.apache.lucene.codecs.bitvectors.*, which include 54 postings, segment-info and for-util tests), 18 skipped, 0 failures; :lucene:core:check :lucene:codecs:check -x test passes with the PR-ready test.
  • Five-format version: applies cleanly; the repro passes with assertions on and off; 272 tests in 8 suites (the repro, the three format suites and the four org.apache.lucene.sandbox.codecs.dedup.* suites) pass, 12 skipped, 0 failures; :lucene:core:check :lucene:codecs:check :lucene:sandbox:check -x test passes; the Dedup probe passes with it.
  • The whole core module was not run.

C: merge serially when the count is not above 1 (validated by the checks)

One line in Lucene99HnswVectorsWriter.createGraphMerger, which all five formats write through, so it covers every format that delegates to that writer: (0, executor) then means what (0, null) means.

if (mergeExec != null && numMergeWorkers > 1) {
  return new ConcurrentHnswMerger(

Trade-offs

It silently ignores an executor the caller passed, and (1, executor) stays rejected while (0, executor) is accepted. It can be combined with A as a second guard.

What the checks validated

  • The repro passes with assertions on and off (the merge runs).
  • 342 tests in 17 suites (lucene99.*, util.hnsw.*) and 553 tests in 24 suites (lucene104.*, TestHnswMergeAbort, TestKnnGraph, bitvectors.*, backward-codecs lucene99.* and lucene102.*) pass, 0 failures; google-java-format, ecj lint and forbidden APIs pass on core.
  • With it shadowed in, the Dedup probe's two merges run.

B (not recommended): reject every count below 1 (validated by the checks)

The hunt's first fix sketch, in the two core formats.

Trade-offs

Breaks today's working (0, null) on 10.2.0+; copied into the backward-codecs constructors it would make five placeholder calls of TestLucene99HnswQuantizedVectorsFormat.testLimits pass for the wrong reason.

What the checks validated

The repro passes with it (standalone runner, the fix shadowed in, assertions on and off); no gradle suite was run.

D (with A or C): fix the stale javadoc

"If larger than 1, a non-null ExecutorService must be passed as mergeExec" has been wrong since #13124/#13190 (9.11.0): a null executor falls back to the merge scheduler's intra-merge executor. mikemccand already pointed this out on luceneutil #507.

Trade-offs

Javadoc only; not run.

Downstream note

In the inspected main-branch code of Elasticsearch, the OpenSearch k-NN plugin and Solr (a targeted read on 2026-10-26, not run, not exhaustive) no path passes fewer than 2 workers together with an executor.

How it was found

Code reading while building a differential test for the concurrent HNSW merge: the merge's worker array is sized by numMergeWorkers, which nothing checks below 1. The finder, an AI tool, then wrote a public-API test that failed on the stock jars. No model checker or solver was involved.

The method in full132 words

Method: non-formal (code reading; no model checker, SMT solver, prover or bounded-exhaustive harness produced the signal).

  • A hunt unit (the upstream change #16794 to the concurrent HNSW merge), 2026-10-26, on main a9779ef3c193. While building a differential harness for that merge, the finder noticed that HnswConcurrentMergeBuilder sizes its worker array by numMergeWorkers and that the only check on the count in any layer is numMergeWorkers == 1 with an executor.
  • It wrote a 4-test public-API JUnit (0 and -1 workers with an executor, the scalar-quantized format, the (0, null) control); 3 of 4 failed with assertions on and off, and a 4-line constructor check made them pass.
  • Independent checkers then widened it: the tragic close of the writer, the default 4-argument constructor, (0, null) failing on 9.11.0-10.1.x, and every release from 9.9.0.

Prior reports, history and checks

Prior reports

No prior report: 249 read-only queries over GitHub issues and PRs, commits, the mailing lists, the JIRA archive, downstream code and the web (2026-10-26), refreshed on 2026-10-30. Closest item: luceneutil #507, where mikemccand called the worker-count javadoc wrong and said the count should not be configurable; it does not mention the missing check or the crash.

Searches5 searches

No issue, PR, review comment, commit, JIRA entry, list thread, downstream issue or web page reports that the HNSW formats accept numMergeWorkers <= 0 or that such a merge throws and closes the writer. Keyword searches cannot prove absence, and private security reports cannot be searched.

Design context: benwtrent's #13124 PR discussion comment (2024-02-23) that users should not configure the worker count. Downstream, the OpenSearch k-NN plugin maps a thread count <= 1 to (1, null) in its own code, a guard, not a report.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26the scripted prior-report search (class, method and parameter names, the exception texts, the validation messages) and about 45 manual searchesno report; #12660 (introduction), #13124/#13190, #13263, #14331 and #16794 never discuss counts below 1
Open PRs2026-10-26the changed files of all 406 open PRs; the diffs of the 11 that touch the affected filesnone adds a lower bound (#16871, #16778 and others)
Threads, lists, JIRA, web2026-10-2646 PR and issue threads read in full plus luceneutil #507; dev@, java-user@, issues@, commits@; JIRA archive; 13 web queriesluceneutil #507 (stale javadoc, count should not be configurable); nothing on the crash
Downstream2026-10-26Elasticsearch, OpenSearch, the OpenSearch k-NN plugin, Solr: issues, PRs and codeno report; the k-NN plugin guards the value in code
Refresh (GitHub)2026-10-29, 2026-10-30issues and PRs created since 2026-10-25 mentioning numMergeWorkers, merge workers, mergeExec, HnswConcurrentMergeBuilder, ConcurrentHnswMerger, Lucene99HnswVectorsFormat or the exception names; upstream commits to the six filesonly the PiPNN issue #16906 and PR #16915, which edits the same constructors without a lower bound; no new commit to the files

History

Wrong since the parameter was added by #12660 (9.9.0, 2023). Later changes moved the exposure (9.11.0-10.1.x fail even without an executor; since 10.4.0 small merges skip the graph) but never added a lower bound. Executed from 9.8.0 (no such API) to 10.6.0 and main; not fixed upstream.

Versions tested7 rows

Introduced by #12660 "Concurrent HNSW Merge" (Patrick Zhai, @zhaih), a8c52e2e19c4 on main and 5fe48424a25d on the 9.x branch, 2023-10-28; CHANGES 9.9.0, New Features. git blame at 645d4fe14a20 puts the (1, executor) check and the worker array on a8c52e2e19c4; the mergeExec != null branch blames to a47ba3369f40 (#12729, a rewrite of the writer file, also 9.9.0) but is already in the #12660 diff.

Later changes: #13190 (9.11.0) added the intra-merge executor path without a > 1 guard, so (0, null) fails on 9.11.0-10.1.x; #14331 (10.2.0) added the guard; #14963 (10.4.0) skips graphs for tiny merges; #15169 (10.4.0) added the scalar-quantized format with the same check; #15979 and #16506 (in 10.6.0 as #16493 and #16594) added the two sandbox Dedup HNSW formats with the same check; #16794 (main only) moved the zero-worker exception from finish() to runInBatches(). Upstream main 2f424fd428da (2026-10-30) has no later commit to the files. Old releases ran on JDK 25, newer than they were built for.

Versions and branches executed, with results
Version or commitVerdictResult
9.8.0 no such APIthe test does not compile: no Lucene99HnswVectorsFormat
9.9.0, 9.9.1, 9.9.2, 9.10.0 first affected0 and -1 workers with an executor fail; (0, null) passes
9.11.0-9.12.3, 10.0.0, 10.1.0 affectedfail; (0, null) fails too, through the intra-merge executor
10.2.0-10.3.2 affectedexecutor cases fail; (0, null) passes again
10.4.0-10.5.2 affectedfail, also the scalar-quantized format; this doc's test fails on 10.4.0 and 10.5.2
10.6.0 affectedthis doc's test fails, assertions on and off (finish, line 244); the two sandbox Dedup formats, new in 10.6.0, fail the same way
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A or C; both Dedup formats fail too

Independent checks

An independent reproducer, a devil's advocate, a reviewer from another model family (OpenAI GPT-5.6, reading only) and an adjudication rated it real, and a credibility audit kept it; a second GPT-5.6 review, which could not run code, returned a low-confidence "not real" while agreeing with the root cause.

Checks and verdicts431 words

All checks ran on main a9779ef3c193 with assertions on and off unless noted; the GPT-5.6 reviews read code only.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateThe 4-test JUnit fails 3 of 4 on main a9779ef3c193 and 10.5.2 (2 run, 2 failed on ea57360912ed); its 2-test 9.x variant fails on 9.12.3 and 9.9.0; the (0, null) control passes; a constructor check makes it pass.
Refute review (OpenAI GPT-5.6, reading only) not real (low; procedural)It could not run Java, and it voted not real on procedure only; it agreed with the root cause, found the inputs in contract and the intended argument weak. A separate run re-ran the repro: 3 of 4 fail.
Independent reproducer realIts own JUnits (default 4-argument constructor, two 1000-vector segments) fail 4 of 7 and assert the closed writer; controls (0, null) and (2, executor) pass; also 10.5.2, 10.1.0, 9.12.3, 9.9.0. Found that (0, null) fails on 9.11.0-10.1.x.
'works as designed' reviewer (OpenAI GPT-5.6, reading only) real (medium)Intended argument weak; separate executed runs fail and show the same tragic close under ConcurrentMergeScheduler.
Devil's advocate realNo javadoc, CHANGES, MIGRATE, test or PR gives 0 or negative counts a meaning; corrected the claim that no upstream test passes 0.
Adjudication confirmed (high)Re-ran the independent reproduction's tests with seed 5EED1234: 4 of 7 fail with assertions on and off; class lowered from 'index unusable' to 'crash'; advised fix A or C.
Credibility audit keepRe-ran the finder's and the independent reproduction's tests with seed A0D17004 (3 of 4 and 4 of 7 fail): a real but low-priority missing-validation bug.
This doc's runs (2026-10-30) reproduced; fixes validatedOn 645d4fe14a20 the test fails through gradle with assertions on and off; fix A and fix C make it pass with their suites; the test also fails on the 10.6.0, 10.5.2 and 10.4.0 jars.
Independent fact check (2026-10-30) bug confirmed; two text errors, fixedRe-ran the test on 645d4fe14a20 and the 10.6.0 and 10.4.0 jars, the doc's gradle command, fixes A and C, the lint claims and a one-vector probe: all as stated. Found the misattributed #13124 quote and the timing range.
Cross-model fact check (OpenAI GPT-5.6, reading only) real; scope too narrow, fixedFound the two sandbox Dedup formats with the same defect (then executed on main and 10.6.0, and fix A extended to five formats and validated), the bit format's testLimits, the last-commit list, the threshold wording and four overclaims. Intended-behaviour case moderate; "claim-low, not noise".

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

Finding record

LFV-589, snapshot of 2026-11-06

Registry title
The HNSW vectors formats (Lucene99HnswVectorsFormat, Lucene104HnswScalarQuantizedVectorsFormat, HnswBitVectorsFormat and, since 10.6.0, the sandbox DedupHnswVectorsFormat and DedupHnswScalarQuantizedVectorsFormat) accept numMergeWorkers <= 0 together with a merge ExecutorService, and then every merge that builds an HNSW graph throws ArrayIndexOutOfBoundsException (0 workers) or NegativeArraySizeException (negative) and closes the IndexWriter as a tragic event (since 9.9.0)
Area
codecs / HNSW vectors formats (constructor parameter validation; concurrent HNSW merge)
Priority
Low, rank 687: The core HNSW formats accept numMergeWorkers <= 0 together with an executor, and every graph merge then closes the writer, which is loud and needs a degenerate computed setting but sits in core formats, so it heads the accepted-setting crash rows ahead of LFV-166 and LFV-226.
Impact class
crash
Impact note
Only applications that build their codec with an HNSW vectors format given numMergeWorkers <= 0 and a non-null merge ExecutorService (the default is 1 worker and no executor; on 9.11.0-10.1.x 0 workers without an executor fails too) are hit: construction, indexing and flushing succeed, but the first merge that builds a graph (from 10.4.0, with the default tinySegmentsThreshold of 100, only a merge of n vectors with n > (int) (100 * ln n), that is from 648 vectors on) throws within milliseconds and the IndexWriter closes as a tragic event, losing its uncommitted changes, and every new writer with that setting fails the same way; -ea and -da, no scale needed; no hang was observed, the last commit stays intact (CheckIndex clean) and a valid setting merges the same index afterwards.
Summary
You build a codec with, for example, new Lucene99HnswVectorsFormat(16, 100, 0, executor), that is zero merge workers plus a merge executor. The constructor already rejects maxConn or beamWidth <= 0 and one worker with an executor, so you expect it to reject 0 as well (or to merge serially, as it does without an executor). Instead it accepts the value, indexing works, and the first merge that builds an HNSW graph throws ArrayIndexOutOfBoundsException (NegativeArraySizeException for -1) inside HnswConcurrentMergeBuilder, whose worker array is sized by that count, and the IndexWriter closes itself with that exception.
Introduced
a8c52e2e19c4ca9bdf71cf476d69d36d3ca9ae52 (main) and its 9.x backport 5fe48424a25dc5e3084beb02593484120adeabdf (shipped in 9.9.0): apache/lucene#12660 'Concurrent HNSW Merge' (author zhaih, merged 2023-10-28). It added the numMergeWorkers / mergeExec constructor parameters with only the (> 1, null) and (== 1, executor) checks, the worker array 'new ConcurrentMergeWorker[numWorker]' and the writer's mergeExec != null branch to ConcurrentHnswMerger [read: blame of Lucene99HnswVectorsFormat.java:289 and HnswConcurrentMergeBuilder.java:153 and git log -S in the full-history clone; the createGraphMerger lines blame to the #12729 file rewrite a47ba3369f4, also 9.9.0, but the branch is in the #12660 diff; exec at release granularity: 9.8.0 has no such API (repro does not compile), 9.9.0 fails]. Later changes moved the exposure without adding a lower bound: #13190 (75e1ebc4501, 9.11.0: intra-merge executor, so 0 without an executor fails too), #14331 (3752dba9c51, branch_10x aa1e9f34b37, 10.2.0: '> 1' guard on the intra-merge path, so (0, null) works again), #14963 (9b957e807aa, 10.4.0: tiny segments skip graph building), #15169 (07033dcd84c, 10.4.0: Lucene104HnswScalarQuantizedVectorsFormat copies the partial check), #16794 (c50bc9523db, main only: the zero-worker exception moves from finish() to runInBatches()). (2023-10-28)
First affected version
9.9.0 [exec: 9.9.0 fails with -ea and -da; 9.8.0 has no Lucene99HnswVectorsFormat or merge-worker parameter, the repro does not compile]
Affected versions
every release from 9.9.0 through 10.6.0 (10.6.0 [exec: RC1 4ae946e5a9b on 2026-10-29, and the release jars with -ea and -da on 2026-10-30]) and main a9779ef3c193 and 645d4fe14a2 (re-run 2026-10-29, -ea and -da), also the previous hunt main ea57360912ed [exec, follow-up, seed DEADBEEF, JDK 25, all 22 releases: 9.9.0-9.9.2, 9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2 fail for 0 and -1 workers with an executor; 9.9.0, 9.12.3, 10.5.2 and main also with -da]. On 9.11.0-10.1.0 the no-executor control (0, null) fails too, through the intra-merge executor path [exec 9.11.0-10.1.0]; on 9.9.x-9.10.0 and 10.2.0+ it passes [exec]. From 10.4.0 a merge must exceed the tiny-segment threshold to reach the bug: with the default 4-argument constructor a 40-vector merge passes and a 2000-vector merge fails on 10.5.2 and main [exec]. Lucene104HnswScalarQuantizedVectorsFormat (10.4.0+) executed on 10.4.0-10.5.2 and main; HnswBitVectorsFormat (lucene/codecs) has the same check [read only, not executed]; the sandbox DedupHnswVectorsFormat and DedupHnswScalarQuantizedVectorsFormat (new in 10.6.0: #15979/#16506, backported as #16493/#16594) have the same check and fail the same way [exec 2026-10-30: main 645d4fe14a20 with -ea and -da, the 10.6.0 release jars with -da]; the backward-codecs Lucene99HnswScalarQuantizedVectorsFormat and Lucene102HnswBinaryQuantizedVectorsFormat share the omission but are read-only formats [read]. branch_10_6 RC1 4ae946e5a9b fails the same [exec 2026-10-29, -ea and -da: 3 of 4, with the zero-worker exception at HnswConcurrentMergeBuilder.finish:244]; branch_10x 657014b3c57 has identical code [read]. Not fixed on upstream main 645d4fe14a2 (2026-10-29), branch_10x or branch_10_6, and no open PR adds a lower bound [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 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 (2026-10-26, plus, read-only; the prior-report record, verdict new; 249 queries, 28 hits recorded, none the same bug): scripted prior-report searches (identifiers, symptom and error text over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), about 80 manual GitHub searches (apache/lucene, Elasticsearch, OpenSearch, OpenSearch k-NN, Solr, luceneutil), the changed files of all 406 open apache/lucene PRs (11 touch the affected or neighbouring files; their diffs add no lower bound), full reads of 46 apache/lucene PR and issue threads plus luceneutil #507, and the upstream history of the affected files. Related items, none the same bug: #12660 (introducing PR; no discussion of counts below 1), #13124 / #13190 and #13263 (removed the (> 1, null) check; benwtrent, in a #13124 PR discussion comment of 2024-02-23: users should not configure numWorkers at all), #14331 (added the '> 1' guard on the intra-merge path only), #16794 (moved the exception site), open #16871 and #16778 (touch the worker code, add no check), luceneutil #507 (mikemccand called the 'If larger than 1, a non-null ExecutorService must be passed' javadoc wrong and said the count should not be configurable; neither the missing lower bound nor the crash). Downstream: the OpenSearch k-NN plugin maps thread counts <= 1 to (1, null) before calling Lucene (code guard, not a report). Open #16915 (PiPNN, 2026-10-26) edits the same constructors and createGraphMerger without a lower bound and copies the stale 'If larger than 1' javadoc; not a fix (recheck 2026-10-29).
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle with assertions on and off (seed DEADBEEF) and on the standalone runner (seeds DEADBEEF and 5EED1234); the same test on the 10.6.0, 10.5.2 and 10.4.0 release jars; the hunt's tests on 22 releases from 9.9.0 to 10.5.2 and on main a9779ef3c193 and ea57360912ed. Fixes A and C were run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-30, and fix A again in its five-format version. A same-shape probe for the two sandbox Dedup HNSW formats ran on main (-ea, -da) and on the 10.6.0 jars. Upstream main 2f424fd428da (2026-10-30) has no later commit to the eight files involved.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:83 (at a9779ef3c193) (@lucene.experimental), :215-216, :232-233, :254-255 ('@param numMergeWorkers number of workers (threads) that will be used when doing merge. If larger than 1, a non-null ExecutorService must be passed as mergeExec'), :221 and :240 (public 4- and 5-argument constructors), :271-283 (maxConn and beamWidth 'must be positive'), :289-292 (only numMergeWorkers == 1 with an executor is rejected), :293 (count stored unchecked)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:511 (at a9779ef3c193) (mergeOneField -> createGraphMerger), :668-689 (createGraphMerger: :674 mergeExec != null -> ConcurrentHnswMerger with the format's count, no check; :678 intra-merge path guarded by numParallelMergeWorkers > 1)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/ConcurrentHnswMerger.java:71-101 (at a9779ef3c193) (createBuilder -> new HnswConcurrentMergeBuilder with numWorker)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:153 (at a9779ef3c193) (workers = new ConcurrentMergeWorker[numWorker]), :374-375 (runInBatches reads workers[0]), :478 and :483 (finish / getGraph read workers[0])
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:47 (at a9779ef3c193) (@lucene.experimental), :155 (public constructor), :181 (same partial check)
  • lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:125 (at a9779ef3c193) (same partial check)
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene99/Lucene99HnswScalarQuantizedVectorsFormat.java:128 (at a9779ef3c193) and backward_codecs/lucene102/Lucene102HnswBinaryQuantizedVectorsFormat.java:106 (same partial check, read-only formats)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5112 (at a9779ef3c193) (IndexWriter.merge: any non-abort Throwable -> tragicEvent(t, "merge"))
  • lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:146 (at a9779ef3c193) ('maxThreadCount should be at least 1', the analogous thread-count check)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:83, (at 645d4fe14a20) :120, :177-181, :215-219, :271-284, :289-293
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:510-518, (at 645d4fe14a20) :668-689
  • lucene/core/src/java/org/apache/lucene/util/hnsw/ConcurrentHnswMerger.java:70-102 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:153, (at 645d4fe14a20) :242-244, :374-375, :478
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:181; (at 645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:125; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswVectorsFormat.java:185, :201; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswScalarQuantizedVectorsFormat.java:201, :215
Who is affected
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104Codec.java:123 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:83; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:47; lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:56; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswVectorsFormat.java:65; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswScalarQuantizedVectorsFormat.java:58
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:453, (at 645d4fe14a20) :712-722; lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:56-57
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:215-216, (at 645d4fe14a20) :227-228, :232-233, :254-255, :271-292
  • lucene/core/src/test/org/apache/lucene/codecs/lucene99/TestLucene99HnswVectorsFormat.java:64-74 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:145-146 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:678 (at 645d4fe14a20)
  • lucene/backward-codecs/src/test/org/apache/lucene/backward_codecs/lucene99/TestLucene99HnswQuantizedVectorsFormat.java:344-357 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104HnswScalarQuantizedVectorsFormat.java:273-295; (at 645d4fe14a20) lucene/codecs/src/test/org/apache/lucene/codecs/bitvectors/TestHnswBitVectorsFormat.java:117-124
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:303; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:195; lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:141; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswVectorsFormat.java:201; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswScalarQuantizedVectorsFormat.java:215 (all formats use Lucene99HnswVectorsWriter)
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene99/Lucene99HnswScalarQuantizedVectorsFormat.java:137-139; (at 645d4fe14a20) lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene102/Lucene102HnswBinaryQuantizedVectorsFormat.java:119-120 (fieldsWriter throws)
History
  • lucene/CHANGES.txt:3159-3160 (at 645d4fe14a20)

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

Repro files

About the diagrams

A step-by-step picture of the constructor's checks, the writer's choice of merger and the empty worker array, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.

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.