The reorderer carries the executor through every task but never calls it. Its tasks are ForkJoin RecursiveActions forked with ForkJoinTask.invokeAll from a thread outside any pool, so each forked half lands on the common pool.
- BP ("recursive graph bisection") renumbers documents so that similar ones get nearby doc ids.
BpVectorReordererdoes it by a float vector field: it splits the documents into two halves around two centroids, moves vectors to the closer half, and recurses into each half. BPReorderingMergePolicyruns a reorderer on each merge.IndexWriterpasses it anExecutorfrom the merge scheduler: by default a same-thread executor ("all intra-merge actions occur in their calling thread");ConcurrentMergeSchedulergives big merges a few threads, bounded by itsmaxThreadCount.- The two halves of each split can run in parallel, and the executor is meant to be how: the sibling
BPIndexReordererruns its halves through it, so a same-thread executor keeps all its work on the caller.
- You reorder by a vector field on merges:
new BPReorderingMergePolicy(mp, new BpVectorReorderer("v")).IndexWriterhands each merge's reorder the scheduler's executor; withSerialMergeScheduleror a small merge that is a same-thread executor. - You expect the reorder to run on the merge thread, or on the executor's threads when it is a pool, as the sibling
BPIndexReordererdoes. - Instead the executor is never called. Reordering 4,096 two-dimensional vectors with the executor
Runnable::runreads vectors on the caller and onForkJoinPool.commonPool-worker-1; the executor receives 0 tasks. - Bigger inputs spread further: with a 2-thread pool as the executor and 65,536 vectors, the pool never starts a thread and 9 common-pool workers share the work. The resulting order is still correct.
Only users of unreleased main who opt in to BpVectorReorderer: every reordering merge (IndexWriter always passes an executor), direct computeDocMap calls with an executor, and the command line. The reorder then runs on common-pool threads (by default up to cores - 1), outside the merge scheduler's thread budget. Results are correct; no slowdown was measured.
Mechanism, terms and numbers644 words
BpVectorReorderer (lucene/misc, main and branch_11x only) renumbers documents by a float vector field, through BPReorderingMergePolicy, computeDocMap(reader, tempDir, executor) or its command line. The merge API describes the executor as the means of parallelism, and IndexWriter always passes the merge scheduler's intra-merge executor, which by default runs everything on the calling thread. The reorderer wraps that executor in a TaskExecutor and carries it through every task, but never calls it: its tasks are ForkJoin RecursiveActions forked with ForkJoinTask.invokeAll from a thread outside any pool, so a non-null executor only switches forking on and each forked half runs on ForkJoinPool.commonPool(). The command line's --thread-count pool never receives a task and is never shut down. The order is correct (each thread reads its own copy of the vectors); the reorder's CPU work just runs on common-pool threads (by default up to cores - 1) outside the merge scheduler's thread budget. The sibling BPIndexReorderer was moved to the executor in #13289, four months before this class was added with the old pattern. Main and branch_11x only; no release has the class.
- The executor is wrapped and carried, never called.
computeDocMapbuildsnew TaskExecutor(executor)when the executor is non-null (BpVectorReorderer.java:580-585) and passes it throughcomputeValueMap(:591,:596-607) to the rootReorderTask(:627). Every task stores it (:151-157) and hands it to its children (:254-257,:526-547). Nothing callsinvokeAll,submitorexecuteon it. - It is only a switch.
shouldForkreturns false when the executor is null (:160-162); otherwise it consults the ForkJoin-onlygetSurplusQueuedTaskCount()(:163-167) and forks at the root and aboveFORK_THRESHOLD = 8192(:78,:168-173). - Forking goes through ForkJoin.
BaseRecursiveAction extends RecursiveAction(:149);ReorderTask(:259-260) andComputeBiasTask(:523-547) callForkJoinTask.invokeAll, which forks one half and runs the other on the current thread. - The caller is outside any pool. The root runs with
.compute()on the calling thread (:627).fork()from a thread that is not in aForkJoinPoolusesForkJoinPool.commonPool(), so a common-pool worker takes the forked half while the caller works on the other; its own forks stay in the common pool. - The command line's pool is dead code.
main()buildsnew ForkJoinPool(threadCount, ...)when--thread-countis not 1 (default: the core count,:660,:673-678) and passes it as the executor (:679-681); nothing submits to it and it is never shut down. - Results stay correct. Each thread gets its own
vectors.copy()(:108-110,:619-625), and tasks work on disjoint slices of the shared arrays (:252-253).
The sibling for comparison: BPIndexReorderer.java:158-191 has a task base class and shouldFork of the same shape (its shouldFork has no queue-depth check) but implements Callable<Void> and forks with executor.invokeAll(...); TaskExecutor submits all but one task to the executor and runs the rest on the calling thread (TaskExecutor.java:37-45).
- Minimal test: 4,096 two-dimensional vectors, executor
Runnable::run: readers are the test thread andForkJoinPool.commonPool-worker-1(gradle-eaand-da; standalone runner 1/1-ea, 10/10 iterations-da; also with-Djava.util.concurrent.ForkJoinPool.common.parallelism=0). - Model leg (executor null, caller-runs, fixed 2-thread pool, 2-thread
ForkJoinPoolx 64 and 4,096 vectors x EUCLIDEAN and DOT_PRODUCT): null stays on the caller; all 12 non-null cells read on the common pool with 0 tasks handed to the executor. With the fix no cell touches the common pool and the executor receives 3 to 5 tasks. - Probe: a 2-thread
ForkJoinPool(the--thread-count 2shape) and 65,536 vectors: 0executecalls, the pool never started a thread, readers were the caller and 9 common-pool workers (common-pool parallelism 31 on that host). With the fix: 206executecalls; the readers were the caller and the pool's 2 workers, no common-pool thread.
Exposure and likelihood316 words
- Who. Users of main or branch_11x (would ship in 11.0.0) who opt in:
BPReorderingMergePolicywith aBpVectorReorderer, on every reordering merge with any merge scheduler, sinceIndexWriteralways passesgetIntraMergeExecutor(merge)(IndexWriter.java:3558-3562foraddIndexes(CodecReader...),:5542-5558for merges;BPReorderingMergePolicy.java:133-138); a directcomputeDocMapcall with a non-null executor; the command line with--thread-countother than 1, which includes the default (the core count) on any multi-core host. - What goes wrong. The reorder's CPU work runs on common-pool threads (with the JVM's default common-pool size, up to cores - 1) instead of the merge thread (
SerialMergeScheduler, "using the current thread"; smallConcurrentMergeSchedulermerges, "don't do multithreaded merges for small merges"), instead ofConcurrentMergeScheduler's intra-merge executor, which "provides a limited number of threads" tied tomaxThreadCount, or instead of the application's own pool. It competes with everything else in the JVM that uses the common pool (parallel streams, default asyncCompletableFuturemethods). Among valid (positive) values,--thread-count Tonly matters as 1 versus not 1; 0 or a negative value makes theForkJoinPoolconstructor throwIllegalArgumentException. - Not hit. The document order is correct; no exception, hang or corruption; a null executor stays on the caller.
- Default vs opt-in, scale. Opt-in class in lucene/misc, used by no default codec or merge policy. Any input big enough to be split (at least 2 x
minPartitionSize, 64 vectors by default): the root split then always forks when an executor is given, and 64 vectors already reach the common pool. Smaller inputs are not split, so nothing reaches the common pool (63 vectors: no reader in 20 of 20 iterations). Same with assertions on and off. - How likely to be noticed. Silent: only applications that budget merge threads or share the common pool feel it, and no public report was found. No benchmark of the harm was run, so it stands as a consistency fix.
One JUnit test, one method, no index, no thread created by the test: reorder 4,096 two-dimensional vectors with a same-thread executor (Runnable::run, like the default merge scheduler's) and record which threads read the vectors. Only the test thread should. It fails with assertions on and off on main.
/*
* 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.misc.index;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.lucene.index.FloatVectorValues;
import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.search.TaskExecutor;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestBpVectorReordererExecutor extends LuceneTestCase {
public void testSameThreadExecutorKeepsWorkOnCaller() {
Set<Thread> readers = ConcurrentHashMap.newKeySet();
FloatVectorValues vectors =
new FloatVectorValues() {
@Override
public float[] vectorValue(int ord) {
readers.add(Thread.currentThread());
return new float[] {ord % 61, ord % 7};
}
@Override
public FloatVectorValues copy() {
return this;
}
@Override
public int dimension() {
return 2;
}
@Override
public int size() {
return 4096;
}
};
new BpVectorReorderer("v")
.computeValueMap(
vectors, VectorSimilarityFunction.EUCLIDEAN, new TaskExecutor(Runnable::run));
// expected: only the caller reads vectors (same-thread executor)
// actual: ForkJoinPool.commonPool workers read some of them too
assertEquals(Set.of(Thread.currentThread()), readers);
}
}
cp $REPRO/TestBpVectorReordererExecutor.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE
./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestBpVectorReordererExecutor -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
- Expected
- Only the test thread reads vectors: a same-thread executor keeps all the work on the caller.
- Observed
AssertionError: the readers are the test thread andForkJoinPool.commonPool-worker-1.
More files, environment, full output316 words
- Same-package test. It calls the package-private
computeValueMap(vectors, similarity, TaskExecutor), whichcomputeDocMapcalls withnew TaskExecutor(executor)and whichTestBpVectorReordereralso uses. The cross-model reviewer reproduced the same through publiccomputeDocMapon anIndexWriter-written 8,192-document index. - How stable.
invokeAllforks one half and computes the other on the caller, which takes long enough that a common-pool worker picks up the forked half. That is scheduling in principle, but it failed in every run observed: gradle 2/2 (the test JVM reportedcpus=1), standalone runner 1/1-eaand 10/10 iterations-da, 10/10 separate-daJVMs, an independent test 10/10 and with-XX:ActiveProcessorCount=1, and with common-pool parallelism 0 (JDK 25 still starts worker-1). - Fully deterministic variant: wrap
Runnable::runin anExecutorthat counts calls and also assert the count is above 0, in this same test. It is 0 on main for every non-null executor and 3 to 5 with the fix; on its own it only shows that the executor is unused. - Format. The study's test, re-wrapped by
applyGoogleJavaFormatwith its comment split in two; it passescheckGoogleJavaFormatandecjLintTest. As a regression test, use this same test.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the class is in no release |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
cp $REPRO/TestBpVectorReordererExecutor.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE
./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestBpVectorReordererExecutor -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
# stock main 645d4fe14a20, gradle, seed DEADBEEF, assertions on (off, seed 5EED1070: same failure)
TestBpVectorReordererExecutor > testSameThreadExecutorKeepsWorkOnCaller FAILED
java.lang.AssertionError: expected:<[Thread[#20,TEST-TestBpVectorReordererExecutor.testSameThreadExecutorKeepsWorkOnCaller-seed#[DEADBEEF],5,TGRP-TestBpVectorReordererExecutor]]> but was:<[Thread[#21,ForkJoinPool.commonPool-worker-1,5,InnocuousForkJoinWorkerThreadGroup], Thread[#20,TEST-TestBpVectorReordererExecutor.testSameThreadExecutorKeepsWorkOnCaller-seed#[DEADBEEF],5,TGRP-TestBpVectorReordererExecutor]]>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:3B24BB7FAA917674]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:120)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.misc.index.TestBpVectorReordererExecutor.testSameThreadExecutorKeepsWorkOnCaller(TestBpVectorReordererExecutor.java:58)
:lucene:misc:test (FAILURE): 1 test, 1 failure
# with the recommended fix: the repro + TestBpVectorReorderer + TestBPIndexReorderer + TestBPReorderingMergePolicy
:lucene:misc:test (SUCCESS): 20 tests, 2 skipped
# the 2 skipped are @Nightly; with -Ptests.nightly=true (TestBPReorderingMergePolicy + TestBpVectorReorderer)
:lucene:misc:test (SUCCESS): 11 tests
# all of lucene/misc
:lucene:misc:test (SUCCESS): 317 tests, 9 skipped- The merge API calls the executor the way to parallelize: "May be null if no concurrency is supported"; the default one runs everything "in their calling thread".
- #13289 moved the sibling
BPIndexReordereronto the executor; this class came four months later with the old code. - Its
--thread-countpool never gets a task.
The full argument253 words
IndexReorderer#computeDocMap: "@param executor may be used to parallelize reordering work." (IndexReorderer.java:32)OneMerge#reorder: "An executor that can be used to parallelize the reordering logic. May be {@code null} if no concurrency is supported." (MergePolicy.java:321-322)MergeScheduler#getIntraMergeExecutor: "By default, the method returns a SameThreadExecutorService where all intra-merge actions occur in their calling thread." (MergeScheduler.java:60-66).SerialMergeSchedulerdoes each merge "using the current thread";ConcurrentMergeSchedulerreturns the same-thread executor for small merges on purpose.- #13289 (Adrien Grand, 2024-09-12): "Index reordering can benefit greatly from parallelism, so it should try to use the intra-merge executor when possible." It moved
BPIndexReordererfromRecursiveActiontoCallabletasks forked throughTaskExecutor.invokeAll, whose javadoc says "A value of null indicates that reordering should be performed in the current thread" (BPIndexReorderer.java:831-832).BpVectorReorderer(#14097, 2025-01-07) has the oldRecursiveActioncode with the newTaskExecutorfield. - The class expects it too. It carries the executor through every task and builds a
--thread-countpool for the command line; neither is used. - Tests.
TestBpVectorReordererbuilds its ownForkJoinPool"to avoid tests failing with FS-based directories" and passes it (:132-144); on main it runs nothing. No test asserts where the work runs. - The class author and other contributors assume it is used. Open #16729, from another contributor, describes the class as using executor capacity at each recursion node; on open PR #15430 the class author credits a merge-time speedup to "passing the mergestate intra-merge executor in", which with this code comes from the common pool.
The devil's-advocate check147 words
The case for intended: read literally, "may be used" and "May be null if no concurrency is supported" make a non-null executor a licence to parallelize; no javadoc names the pool the work must run on; results are correct and OneMerge#reorder is experimental. The cross-model reviewer (OpenAI GPT-5.6, refute lens) rated this a moderate argument.
Why it fails. (1) Lucene's own schedulers pass a non-null same-thread executor precisely to mean "no concurrency"; under the licence reading every reordering SerialMergeScheduler merge would spread over the common pool, contradicting "using the current thread". (2) "May be used" permits not parallelizing, not parallelizing on a different, JVM-wide pool. (3) The carried-but-unused executor and the dead command-line pool show the work was meant to run there. (4) #13289 fixed the same pattern in the sibling for this reason. The reviewer, the intended-behaviour devil's advocate and the adjudication all called it real.
Recommended A. Fork through the given executor, as BPIndexReorderer does since #13289
Port #13289's pattern from BPIndexReorderer: make the tasks Callable<Void> forked through TaskExecutor.invokeAll, drop the ForkJoin-only queue check, and shut down the command line's pool. Validated with gradle: the repro passes, and so do the reorder suites (incl. nightly), all of lucene/misc, and the format and lint checks.
All options and trade-offs3 options ยท 705 words
Option A was applied in a scratch worktree of main 645d4fe14a20 and run with gradle and the standalone JUnit runner; B and C are alternatives from reading the code, not run. Workaround today (from the code, not run as such; the null-executor controls stay on the caller): wrap the reorderer so it gets a null executor, for example new BPReorderingMergePolicy(mp, (reader, dir, executor) -> bpv.computeDocMap(reader, dir, null)); the reorder then stays on the merge thread, serially (shouldFork returns false for null).
A. Fork through the given executor, as BPIndexReorderer does since #13289 (validated by the checks) (recommended)
BaseRecursiveAction implements Callable<Void> with an abstract compute(); call() runs it, and a local invokeAll(BaseRecursiveAction...) forks through executor.invokeAll(Arrays.asList(actions)) (as in BPIndexReorderer.java:183-190). Drop the ForkJoin-only getSurplusQueuedTaskCount() check. Shut down the pool that main() creates: without that, the command-line pool now starts threads that are never stopped, and the upstream suite fails its thread-leak check.
diff --git a/lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java b/lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java
index f2b987b3a51..9c490d9c387 100644
--- a/lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java
+++ b/lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java
@@ -22,10 +22,10 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.Arrays;
+import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread;
-import java.util.concurrent.RecursiveAction;
import org.apache.lucene.index.CodecReader;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.FieldInfo;
@@ -146,7 +146,7 @@ public class BpVectorReorderer extends AbstractBPReorderer {
}
}
- private abstract class BaseRecursiveAction extends RecursiveAction {
+ private abstract class BaseRecursiveAction implements Callable<Void> {
protected final TaskExecutor executor;
protected final int depth;
@@ -156,15 +156,27 @@ public class BpVectorReorderer extends AbstractBPReorderer {
this.depth = depth;
}
+ protected abstract void compute();
+
+ @Override
+ public Void call() {
+ compute();
+ return null;
+ }
+
+ protected final void invokeAll(BaseRecursiveAction... actions) {
+ assert executor != null : "Only call invokeAll if shouldFork returned true";
+ try {
+ executor.invokeAll(Arrays.asList(actions));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
protected final boolean shouldFork(int problemSize, int totalProblemSize) {
if (executor == null) {
return false;
}
- if (getSurplusQueuedTaskCount() > 3) {
- // Fork tasks if this worker doesn't have more queued work than other workers
- // See javadocs of #getSurplusQueuedTaskCount for more details
- return false;
- }
if (problemSize == totalProblemSize) {
// Sometimes fork regardless of the problem size to make sure that unit tests also exercise
// forking
@@ -678,6 +690,10 @@ public class BpVectorReorderer extends AbstractBPReorderer {
}
try (Directory dir = FSDirectory.open(Path.of(directory))) {
reorderer.reorderIndexDirectory(dir, executor);
+ } finally {
+ if (executor instanceof ForkJoinPool pool) {
+ pool.shutdown();
+ }
}
}
Trade-offs
Parallelism is then bounded by the given executor: a same-thread executor (serial merges, small ConcurrentMergeScheduler merges) gives a serial reorder, so a reorder that today silently gets common-pool parallelism may take longer, and speedups measured with the current code (such as the one reported on #15430) may change. Forking is decided by size alone, as in BPIndexReorderer. No benchmark was run. Open PR #15430 moves the class, so the fix site may move.
What the checks validated
Gradle, scratch worktree of 645d4fe14a20, JDK 25 (2026-11-02): the minimal test passes with assertions on (DEADBEEF) and off (5EED1070); TestBpVectorReorderer 8/8, TestBPIndexReorderer 8/8, TestBPReorderingMergePolicy 3/3 with -Ptests.nightly=true; all of :lucene:misc:test (317 tests, 9 skipped, 0 failures); checkGoogleJavaFormat, ecjLintMain, ecjLintTest. Standalone runner on all cores: the upstream suite passes 8/8 with the fix and fails its thread-leak check (ForkJoinPool-1-worker-1 leaked) without the main() shutdown. The study: no common-pool reads in any model cell, 3 to 5 tasks reach the executor; with a 2-thread pool and 65,536 vectors, 206 execute calls, and the readers were the caller and the pool's 2 workers (no common-pool thread).
What BPIndexReorderer did before #13289 (it submitted its root task to the given pool); do not fork for any other executor. Keeps RecursiveAction.
Trade-offs
The merge schedulers' executors are not ForkJoinPools (MergeScheduler.java:38, ConcurrentMergeScheduler.java:945), so every merge would reorder serially, the opposite of #13289's goal. Not run.
Say that the executor only switches forking on and the common pool does the work.
Trade-offs
Contradicts OneMerge#reorder, SerialMergeScheduler and the sibling's contract, and leaves --thread-count meaningless. Not recommended.
Applications that cap merge threads, or run latency-sensitive work on the common pool, get extra common-pool load from vector BP reordering on main until this is fixed; results are unaffected.
Non-formal. A hunt unit testing the misc reorderers saw identical doc maps for null, pool, direct and rejecting executors, read the executor handling and witnessed the bug with a test that records the reading threads. A follow-up unit reduced it to one test and ran an executor, size and similarity matrix on main and on the fix.
The method in full112 words
- Lead. A unit on the misc reorder and split tools (2026-10-26) ran a determinism differential (null,
ForkJoinPool, direct and rejecting executors gave identical doc maps), then read the reorderers' executor plumbing and witnessed the defect with a JUnit that records which threads read the vectors. - Adjudication unit. Re-ran the lead, reduced it to one JUnit and ran a bounded differential over executor kind {null, caller-runs, fixed pool, ForkJoinPool} x size {64, 4096} x similarity {EUCLIDEAN, DOT_PRODUCT} on stock main and with the fix, counting tasks that reach the executor.
- Verification. An independent reproducer, an executing cross-model review (OpenAI GPT-5.6, refute lens), an intended-behaviour devil's advocate, an adjudication and a credibility audit.
No prior report found (searched 2026-11-01, re-checked 2026-11-02): GitHub issues and PRs incl. the JIRA archive, commits, the files of all 410 open PRs, mailing lists, downstream trackers and the web. Closest: #13289, which fixed the same pattern in the sibling BPIndexReorderer, and open PR #15430, which moves this class unchanged.
Searches4 searches
Related, not reports of this bug: #13289 (merged 2024-09-12) moved BPIndexReorderer to TaskExecutor.invokeAll before this class existed, the fix shape; open PR #15430 (BP Reordering Codec) moves the class to core with the same code, and its test has // FIXME test with an executor sometimes; open #16729 describes the class as using the executor at each recursion node; open #16847 and #15285 change other lines of the file, and #16571 touches BPReorderingMergePolicy but not the executor. Downstream mentions (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr) only cite the class or the policy. Private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), Elasticsearch, OpenSearch, OpenSearch k-NN, Solr, mailing lists, JIRA, web | 2026-11-01 | two automated prior-art passes (51 and 57 queries: class names, RecursiveAction, commonPool, ForkJoinPool, intra-merge executor, thread-count, IndexReorderer), 12 web queries | no report |
| GitHub search (apache/lucene and all of GitHub) | 2026-11-01, re-checked 2026-11-02 | 14 manual queries; every issue and PR updated since 2026-10-29 | no report |
| Open apache/lucene PRs | 2026-11-01 | changed files of all 410 open PRs | #15430 moves the class unchanged; #16847, #15285, #16571 touch other code |
| Related threads | 2026-11-01 | comments and reviews of #13289, #14097, #14110, #14117, #13565, #15219, #15430, #15606, #14758, #15003, #16729, #14066, #15684, #12568 | none discusses the executor being unused |
Introduced with the class by #14097 (02a09f6ab12, 2025-01-07), four months after #13289 had fixed the same pattern in the sibling. Main and branch_11x only: no release has the class, and 9.12.3 to 10.6.0 do not compile the repro. Not fixed upstream.
Versions tested6 rows
- Introduced:
02a09f6ab12, #14097 "HNSW BP reordering" (Michael Sokolov, 2025-01-07). It addedBpVectorReordererand theIndexReordererinterface, and changed theBPReorderingMergePolicyconstructor's parameter type fromBPIndexReorderertoIndexReorderer, in one commit;git blameputs every defect line on it.CHANGES.txtlists GITHUB#14097 under 11.0.0 New Features. - Context: #13289 (
ff8b81afc59, 2024-09-12) had movedBPIndexReordererfromRecursiveActiontoCallabletasks andTaskExecutor.invokeAll; before that,BPIndexReorderersubmitted its root task to the given pool, so its forks stayed there. - Later commits to the file,
b7c7fe064ce(#14117) and54283150a81(#16255, 2026-06-14), do not touch the defect lines. Upstream mainf815e679bf6(2026-11-01) and branch_11x have the same file. It can be fixed before 11.0.0 ships.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle and the standalone runner, assertions on and off) |
main ea57360912ed, 7cecf795236c | affected | first repro fails |
02a09f6ab12 (#14097) | introducing commit | first repro fails with -ea and -da |
parent 72d93de50c2 | not affected | class absent (compile error) |
| 10.6.0, 10.5.2, 10.2.0, 10.1.0, 9.12.3 | not affected | class absent (compile error); branch_10_6 and branch_10x by reading |
upstream main f815e679bf6, branch_11x | by reading | same file, no newer commit |
Real by the finder, an independent reproducer, the cross-model refute reviewer (OpenAI GPT-5.6), an intended-behaviour devil's advocate and the adjudication (high confidence, Low); confirmed by the credibility audit, which rated it a consistency fix.
Checks and verdicts235 words
The cross-model review ran on main ea57360912ed, whose BpVectorReorderer.java is identical to 645d4fe14a20; every other check ran on 645d4fe14a20 or older commits and releases.
| Check | Verdict | What it found |
|---|---|---|
| first finder (misc reorder/split unit) | lead | Read the executor plumbing after a determinism differential; JUnit recording the reading threads. |
| finder (judge unit) | reproduced, Low | One JUnit (12/12 JVMs); model leg: all non-null executors read on the common pool, 0 tasks to the executor; none with the fix. |
| independent reproducer | real | Own 16,384-vector test fails -ea/-da, 10/10 JVMs and with ActiveProcessorCount=1; passes with the fix; 2-thread pool probe. |
| cross-model reviewer, refute lens (OpenAI GPT-5.6, executing) | real (high) | Through public computeDocMap on an IndexWriter-written index: common-pool reads, 0 tasks to the executor; null control on the caller. |
| intended-behaviour devil's advocate | real (Low) | No surviving documentation, misuse or reachability argument. |
| adjudication | confirmed (high, Low) | Re-ran the repro with -da and a new seed: fails the same way. |
| minimal-repro check | reproduced, Low | Minimal test fails -ea/-da, 10/10 -da JVMs, passes with the fix. |
| credibility audit | confirmed | Fresh-seed re-run fails; minimal impact: a consistency fix. |
| this doc's gradle validation | fails on stock, passes with fix A | Scratch worktree: stock fails -ea/-da; fix A passes the reorder suites, all of lucene/misc, format and lint; the upstream suite leaks a pool thread without the main() shutdown. |
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 501 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,285 words
- Registry title
- BpVectorReorderer never calls the Executor it is given; any non-null executor makes it fork its work onto ForkJoinPool.commonPool()
- Area
- misc / index: BpVectorReorderer (BP doc-id reordering over a vector field, through BPReorderingMergePolicy, computeDocMap and the command line)
- Priority
- Low, rank 760: Only on unreleased main and branch_11x and only for the opt-in BpVectorReorderer, the given executor is ignored and the work runs on the JVM-wide common pool. That changes thread placement and CPU contention but not the computed order, so it is Low.
- Impact class
- minor
- Impact note
- Only users of unreleased main and branch_11x who opt in to BpVectorReorderer (BPReorderingMergePolicy with a BpVectorReorderer, a direct computeDocMap call with an executor, or the command line with --thread-count other than 1) are hit, and then on every reorder of an input big enough to be split (at least 2 x minPartitionSize, 64 vectors by default): the reordering CPU work runs on the JVM-wide common pool (by default up to cores - 1 threads) instead of the given executor or the merge scheduler's thread budget, also when the scheduler passes a same-thread executor to mean 'no concurrency'; the computed order is correct, so the effect is thread placement and CPU contention only.
- Summary
- You reorder a segment with BpVectorReorderer and pass an Executor, for example the same-thread intra-merge executor that IndexWriter hands to BPReorderingMergePolicy under SerialMergeScheduler, or the pool the command line builds for --thread-count, and expect the work to run on the calling thread or on that executor, as it does for the sibling BPIndexReorderer. Instead the executor is never called: the tasks are RecursiveActions forked with ForkJoinTask.invokeAll from a thread outside any ForkJoinPool, so any non-null executor only switches forking on and the forked work runs on ForkJoinPool.commonPool(). With a run-on-caller executor and 4,096 two-dimensional vectors, vectors are read on a common-pool worker as well as on the caller; the resulting order is still correct.
- Introduced
- 02a09f6ab124e994fd1af42965617c8b5ec67bcc, apache/lucene#14097 'HNSW BP reordering' (by msokolov; author date = commit date 2025-01-07T14:41:31Z, PR merged 2025-01-07T14:41:35Z): it added BpVectorReorderer, the IndexReorderer interface ('@param executor may be used to parallelize reordering work.') and the BPReorderingMergePolicy(MergePolicy, IndexReorderer) constructor together, with RecursiveAction tasks that fork through ForkJoinTask.invokeAll and a TaskExecutor field that only shouldFork null-checks [exec: the repro fails at 02a09f6ab12 with -ea and -da and does not compile at its parent 72d93de50c2; read: git blame attributes all 98 checked defect lines to it; the two later commits to the file, b7c7fe064ce (#14117) and 54283150a81 (#16255), do not touch them]. Context, not the defect: #13289 (ff8b81afc59, 2024-09-12) had moved the sibling BPIndexReorderer from RecursiveAction to Callable tasks forked through TaskExecutor.invokeAll four months earlier. (2025-01-07)
- First affected version
- unreleased (main and branch_11x; CHANGES.txt lists GITHUB#14097 under 11.0.0 New Features; BpVectorReorderer is absent from every 9.x and 10.x release including 10.6.0 (4ae946e5a9b) and from branch_10_6 and branch_10x [exec: 9.12.3, 10.1.0, 10.2.0, 10.5.2 and 10.6.0 do not compile the repro; read: git ls-tree]); the first affected release would be 11.0.0 unless it is fixed first
- Affected versions
- main from 02a09f6ab12 (2025-01-07) through 645d4fe14a20 (2026-10-28) [exec: the repro fails at 02a09f6ab12 (-ea and -da, javac build at the commit), 7cecf795236c, ea57360912ed and 645d4fe14a20 (-ea and -da)]; upstream main f815e679bf6 (2026-11-01) and branch_11x da3ea39fa59 [read: no commit to the file since 54283150a81 (2026-06-14); branch_11x has the same blob]. Not affected, the API is absent: the parent 72d93de50c2, 9.12.3, 10.1.0, 10.2.0, 10.5.2 and 10.6.0 [exec: compile error 'cannot find symbol BpVectorReorderer']; branch_10_6 and branch_10x [read]. All runs on Linux aarch64 with JDK 25.0.3.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal
- Upstream and prior reports
- none found (2026-11-01, re-checked 2026-11-02): two scripted prior-report searches (51 and 57 queries: apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch, OpenSearch k-NN and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, 12 web queries); 14 manual GitHub searches; the changed files of all 410 open apache/lucene PRs; every apache/lucene issue and PR updated since 2026-10-29; comment and review scans of #13289, #14097, #14110, #14117, #13565, #15219, #15430, #15606, #14758, #15003, #16729, #14066, #15684 and #12568; author and committer dates of every commit to BpVectorReorderer.java, IndexReorderer.java and BPReorderingMergePolicy.java. Related, not reports: #13289 (fixed the same pattern in the sibling BPIndexReorderer in 2024, before this class existed: the fix shape); open #15430 (BP Reordering Codec: moves the class to core with the same RecursiveAction code; a maintainer there reports a speedup 'if I enable concurrent reordering by passing the mergestate intra-merge executor in'); open #16729 (describes BpVectorReorderer as re-offering work to the executor at each recursion node, i.e. assumes the executor is used); open #16847 and #15285 (other hunks of the file). Re-check: the GitHub search for BpVectorReorderer returns only those items, no apache/lucene issue or PR updated since 2026-11-01 matches reorder, executor, ForkJoin or common pool, and upstream main is f815e679bf6 with BpVectorReorderer.java last changed by 54283150a81 (2026-06-14). Not fixed upstream.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test through gradle with assertions on and off, stock (fails) and with the recommended fix (passes, withTestBpVectorReorderer,TestBPIndexReorderer,TestBPReorderingMergePolicyincl. nightly, all oflucene/misc,checkGoogleJavaFormatand ecj lint), in a scratch worktree (2026-11-02); through the standalone JUnit runner on main (1/1 with-ea, 10/10 iterations with-da, and with common-pool parallelism 0), and the upstream suite with the fix without and with the command-line pool shutdown. The study's first repro at the introducing commit02a09f6ab12,7cecf795236candea57360912ed; the parent72d93de50c2, 9.12.3, 10.1.0, 10.2.0, 10.5.2 and 10.6.0 do not have the class. Upstream mainf815e679bf6(2026-11-01) is unchanged at the fix site.
- Finding record
lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:149-175 (BaseRecursiveAction extends RecursiveAction; executor only null-checked in shouldFork; getSurplusQueuedTaskCount at 163), 259-263 and 524-546 (ForkJoinTask.invokeAll), 577-592 (computeDocMap wraps the Executor in a TaskExecutor), 627 (root ReorderTask run with compute() on the caller), 671-681 (main(): --thread-count ForkJoinPool never receives a task and is never shut down)lucene/misc/src/java/org/apache/lucene/misc/index/IndexReorderer.java:32 ('@param executor may be used to parallelize reordering work.'); BPReorderingMergePolicy.java:133-138 (forwards the merge's executor to computeDocMap); BPIndexReorderer.java:158-191, 831-832 (the sibling: Callable tasks through TaskExecutor.invokeAll; 'A value of null indicates that reordering should be performed in the current thread')lucene/core/src/java/org/apache/lucene/index/MergeScheduler.java:59-66 (getIntraMergeExecutor default: 'all intra-merge actions occur in their calling thread'); MergePolicy.java:321-322 (OneMerge#reorder: 'An executor that can be used to parallelize the reordering logic. May be null if no concurrency is supported.'); SerialMergeScheduler.java class javadoc ('using the current thread'); ConcurrentMergeScheduler.java:275-282 (small merges get the same-thread executor); IndexWriter.java:3558-3562 and 5542-5558 (always pass getIntraMergeExecutor(merge) to OneMerge#reorder)lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:134-138 (the upstream test builds its own ForkJoinPool and passes it)
- The finding in 30 seconds
lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:78, :108-110, :149-175, :252-260, :523-547, :577-607, :619-627, :653-682lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:158-191 (Callable tasks, TaskExecutor.invokeAll)lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:37-45 (class javadoc)lucene/core/src/java/org/apache/lucene/index/MergeScheduler.java:60-66; lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:275-283, :939-945lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:168-178 (shouldFork without a queue-depth check)
- Who is affected
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3558-3562, :5542-5558lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:133-138lucene/core/src/java/org/apache/lucene/index/SerialMergeScheduler.java:21; lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:275-283, :939-945, :971lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:160-173 (fork switch, root always forks), :653-682 (command line)lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:94, :208-211 (no split below 2 x minPartitionSize); lucene/misc/src/java/org/apache/lucene/misc/index/AbstractBPReorderer.java:25 (DEFAULT_MIN_PARTITION_SIZE = 32)lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:660, :673-678 (pool only for thread counts other than 1); JDK 25 java/util/concurrent/ForkJoinPool.java:3020-3021 (parallelism <= 0 throws IllegalArgumentException)
- Why it looks unintended
lucene/misc/src/java/org/apache/lucene/misc/index/IndexReorderer.java:32lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:321-322lucene/core/src/java/org/apache/lucene/index/MergeScheduler.java:60-66; SerialMergeScheduler.java:21; lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:275-283lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:158-191, :831-832lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:132-144
- Suggested fix
lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:158-191; lucene/core/src/java/org/apache/lucene/index/MergeScheduler.java:38; lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:945lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:160-162 (null executor never forks)
- History
lucene/CHANGES.txt:164
- TestBpVectorReordererExecutor.java:
repro/LFV-1070/TestBpVectorReordererExecutor.java(60 lines)
assets/LFV-1070/bug.png(501 KB)
