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.
- Each segment of a kNN vector field holds an HNSW graph. A merge combines segments, so it builds or joins their graphs into one.
- By default one thread does that.
Lucene99HnswVectorsFormat(maxConn, beamWidth, numMergeWorkers, mergeExec)lets the merge usenumMergeWorkersthreads from your ownExecutorService(since 9.9.0). - 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.
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.- 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. - Index two documents with one 2-dimensional vector each, committing after each (two segments), and call
forceMerge(1). - You expect an
IllegalArgumentExceptionfrom the constructor, as formaxConn0 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. - You get
ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0from the merge, and the writer closes itself, losing its uncommitted changes. -1 workers givesNegativeArraySizeException. With the default 4-argument constructor a merge of two 1000-vector segments fails the same way. - Why: with a pool, the writer always picks the concurrent merger, which makes an array of
numMergeWorkersworkers and reads worker 0. With 0 workers the array is empty.
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.
- Vectors format: the codec component that writes a field's kNN vectors and its HNSW graph.
Lucene99HnswVectorsFormatis the default one;Lucene104HnswScalarQuantizedVectorsFormat(core),HnswBitVectorsFormat(lucene/codecs) and the sandbox'sDedupHnswVectorsFormatandDedupHnswScalarQuantizedVectorsFormat(since 10.6.0) take the same merge arguments. numMergeWorkers,mergeExec: "number of workers (threads) that will be used when doing merge" and theExecutorServicethey run on (Lucene99HnswVectorsFormat.java:215-219). The default constructor uses 1 worker and no executor (:120,:177-185).- Tragic event: an unexpected exception inside
IndexWriterthat makes it roll back to the last commit and close itself; later calls throwAlreadyClosedException.
- No lower bound. The constructor rejects
maxConnandbeamWidthoutside their range ("must be positive ...",Lucene99HnswVectorsFormat.java:271-284) andnumMergeWorkers == 1 && mergeExec != null(:289-292), then stores the count as is (:293). 0 and -1 pass.Lucene104HnswScalarQuantizedVectorsFormat.java:181,HnswBitVectorsFormat.java:125,DedupHnswVectorsFormat.java:185andDedupHnswScalarQuantizedVectorsFormat.java:201have the same check; both Dedup formats fail the same way (executed on main and 10.6.0). - The executor decides the merger, not the count.
Lucene99HnswVectorsWriter.createGraphMergerreturns aConcurrentHnswMergerwith the format's count whenevermergeExec != 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). - An array of 0 workers.
HnswConcurrentMergeBuilderdoesworkers = new ConcurrentMergeWorker[numWorker](HnswConcurrentMergeBuilder.java:153). For -1 that throwsNegativeArraySizeException; for 0 the array is empty. - Worker 0 is read. Joining the segments' graphs goes through
runInBatches, whose first statement readsworkers[0].batchSize(:374-375):ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0. Without graphs to join,finish()readsworkers[0](:478) and fails the same way; that is the site on 10.4.0-10.6.0. - The writer closes.
IndexWriter.mergecatches anyThrowableand callstragicEvent(t, "merge")(IndexWriter.java:5109-5113).
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
numMergeWorkersand anExecutorService, and computes or forwards the count:availableProcessors() / 2on 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; withConcurrentMergeSchedulerthe indexing thread saw the error within 0.6 s of starting to index, as "this writer hit an unrecoverable error" orAlreadyClosedException. 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
CheckIndexis 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 (
tinySegmentsThreshold100), any merge large enough to build a graph: a merge ofnvectors builds one whenn > (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.
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.
/*
* 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);
}
}
}
}
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, orforceMerge(1)merges the two segments. - Observed
forceMerge(1)throwsArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0atHnswConcurrentMergeBuilder.runInBatches(line 374).
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.
| lucene | main 645d4fe14a20: affected; also the 10.6.0, 10.5.2 and 10.4.0 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
# 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)- The javadoc calls it the "number of workers (threads)" for the merge, with no lower bound and no exception.
- The same constructor rejects
maxConnorbeamWidthbelow 1 and 1 worker with a pool;ConcurrentMergeSchedulerrejects 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.
maxConnandbeamWidth"must be positive" (:271-284) and(1, executor)is rejected (:289-292);TestLucene99HnswVectorsFormat.testLimitsasserts these (:64-74), and so does the quantized format's (TestLucene104HnswScalarQuantizedVectorsFormat.java:273-295); the bit format'stestLimitschecks onlymaxConnandbeamWidth(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> 1guard,Lucene99HnswVectorsWriter.java:678, from #14331); open PR #16871 also readsnumMergeWorkers <= 1as 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.
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, unchangedAll 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.
+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, unchangedTrade-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
fieldsWriterthrowsUnsupportedOperationException, so they need no change; writable subclasses of them exist only in backward-codecs tests. - One combined check
mergeExec != null && numMergeWorkers <= 1would 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 testpasses 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 testpasses; the Dedup probe passes with it. - The whole core module was not run.
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-codecslucene99.*andlucene102.*) 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.
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.
"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.
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.
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 thatHnswConcurrentMergeBuildersizes its worker array bynumMergeWorkersand that the only check on the count in any layer isnumMergeWorkers == 1with 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | the scripted prior-report search (class, method and parameter names, the exception texts, the validation messages) and about 45 manual searches | no report; #12660 (introduction), #13124/#13190, #13263, #14331 and #16794 never discuss counts below 1 |
| Open PRs | 2026-10-26 | the changed files of all 406 open PRs; the diffs of the 11 that touch the affected files | none adds a lower bound (#16871, #16778 and others) |
| Threads, lists, JIRA, web | 2026-10-26 | 46 PR and issue threads read in full plus luceneutil #507; dev@, java-user@, issues@, commits@; JIRA archive; 13 web queries | luceneutil #507 (stale javadoc, count should not be configurable); nothing on the crash |
| Downstream | 2026-10-26 | Elasticsearch, OpenSearch, the OpenSearch k-NN plugin, Solr: issues, PRs and code | no report; the k-NN plugin guards the value in code |
| Refresh (GitHub) | 2026-10-29, 2026-10-30 | issues and PRs created since 2026-10-25 mentioning numMergeWorkers, merge workers, mergeExec, HnswConcurrentMergeBuilder, ConcurrentHnswMerger, Lucene99HnswVectorsFormat or the exception names; upstream commits to the six files | only the PiPNN issue #16906 and PR #16915, which edits the same constructors without a lower bound; no new commit to the files |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.8.0 | no such API | the test does not compile: no Lucene99HnswVectorsFormat |
| 9.9.0, 9.9.1, 9.9.2, 9.10.0 | first affected | 0 and -1 workers with an executor fail; (0, null) passes |
| 9.11.0-9.12.3, 10.0.0, 10.1.0 | affected | fail; (0, null) fails too, through the intra-merge executor |
| 10.2.0-10.3.2 | affected | executor cases fail; (0, null) passes again |
| 10.4.0-10.5.2 | affected | fail, also the scalar-quantized format; this doc's test fails on 10.4.0 and 10.5.2 |
| 10.6.0 | affected | this 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 | affected | this 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 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | The 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 | real | Its 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 | real | No 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 | keep | Re-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 validated | On 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, fixed | Re-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, fixed | Found 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". |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 531 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,590 words
- 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 maina9779ef3c193andea57360912ed. Fixes A and C were run through gradle in a scratch worktree of645d4fe14a20on 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 main2f424fd428da(2026-10-30) has no later commit to the eight files involved.
- 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-293lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:510-518, (at 645d4fe14a20) :668-689lucene/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, :478lucene/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:58lucene/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-292lucene/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)
- TestHnswZeroMergeWorkers.java (1 test):
repro/LFV-589/src/org/apache/lucene/codecs/lucene99/TestHnswZeroMergeWorkers.java(59 lines)
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.
assets/LFV-589/bug.png(531 KB)
![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.](../assets/LFV-589/bug.png)