All findings · Lucene finding · LFV-1070 · bpvectorreorderer-ignores-executor-uses-common-pool

BpVectorReorderer never calls the Executor it is given and forks its work onto ForkJoinPool.commonPool()

BpVectorReorderer never uses the executor you pass it: any non-null executor only turns forking on, and the forked-off work runs on the JVM-wide ForkJoinPool.commonPool(), even with the same-thread executor that means "stay on my thread". The order is correct; only where the CPU work runs is wrong (unreleased main).

Summary

Priority
Low, rank 760Only 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
minor
Since
#14097 (2025-01-07), on main; not in any 10.x branchintroduced 2025-01-07 · about 1 year
Affected
main and branch_11x (11.0.0-SNAPSHOT); in no release; not fixed upstream
Status
documented
Found by
non-formal: reading the reorderers' executor handling, witnessed by a thread-recording 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 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.

Background: BP reordering and the merge executor

  1. BP ("recursive graph bisection") renumbers documents so that similar ones get nearby doc ids. BpVectorReorderer does 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.
  2. BPReorderingMergePolicy runs a reorderer on each merge. IndexWriter passes it an Executor from the merge scheduler: by default a same-thread executor ("all intra-merge actions occur in their calling thread"); ConcurrentMergeScheduler gives big merges a few threads, bounded by its maxThreadCount.
  3. The two halves of each split can run in parallel, and the executor is meant to be how: the sibling BPIndexReorderer runs its halves through it, so a same-thread executor keeps all its work on the caller.
Diagram of the bug: the caller passes a same-thread executor to BpVectorReorderer. At a split, one half runs on the caller and the forked half goes to ForkJoinPool.commonPool() instead of the executor, which receives 0 tasks. The resulting document order is the same either way.
One split of the reorder: the same-thread executor receives nothing, and the forked half runs on a common-pool worker. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You reorder by a vector field on merges: new BPReorderingMergePolicy(mp, new BpVectorReorderer("v")). IndexWriter hands each merge's reorder the scheduler's executor; with SerialMergeScheduler or a small merge that is a same-thread executor.
  2. You expect the reorder to run on the merge thread, or on the executor's threads when it is a pool, as the sibling BPIndexReorderer does.
  3. Instead the executor is never called. Reordering 4,096 two-dimensional vectors with the executor Runnable::run reads vectors on the caller and on ForkJoinPool.commonPool-worker-1; the executor receives 0 tasks.
  4. 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.

Who is affected

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 mechanism (main 645d4fe14a20)

  1. The executor is wrapped and carried, never called. computeDocMap builds new TaskExecutor(executor) when the executor is non-null (BpVectorReorderer.java:580-585) and passes it through computeValueMap (:591, :596-607) to the root ReorderTask (:627). Every task stores it (:151-157) and hands it to its children (:254-257, :526-547). Nothing calls invokeAll, submit or execute on it.
  2. It is only a switch. shouldFork returns false when the executor is null (:160-162); otherwise it consults the ForkJoin-only getSurplusQueuedTaskCount() (:163-167) and forks at the root and above FORK_THRESHOLD = 8192 (:78, :168-173).
  3. Forking goes through ForkJoin. BaseRecursiveAction extends RecursiveAction (:149); ReorderTask (:259-260) and ComputeBiasTask (:523-547) call ForkJoinTask.invokeAll, which forks one half and runs the other on the current thread.
  4. The caller is outside any pool. The root runs with .compute() on the calling thread (:627). fork() from a thread that is not in a ForkJoinPool uses ForkJoinPool.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.
  5. The command line's pool is dead code. main() builds new ForkJoinPool(threadCount, ...) when --thread-count is 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.
  6. 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).

The numbers (executed)

  • Minimal test: 4,096 two-dimensional vectors, executor Runnable::run: readers are the test thread and ForkJoinPool.commonPool-worker-1 (gradle -ea and -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 ForkJoinPool x 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 2 shape) and 65,536 vectors: 0 execute calls, 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: 206 execute calls; 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: BPReorderingMergePolicy with a BpVectorReorderer, on every reordering merge with any merge scheduler, since IndexWriter always passes getIntraMergeExecutor(merge) (IndexWriter.java:3558-3562 for addIndexes(CodecReader...), :5542-5558 for merges; BPReorderingMergePolicy.java:133-138); a direct computeDocMap call with a non-null executor; the command line with --thread-count other 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"; small ConcurrentMergeScheduler merges, "don't do multithreaded merges for small merges"), instead of ConcurrentMergeScheduler's intra-merge executor, which "provides a limited number of threads" tied to maxThreadCount, or instead of the application's own pool. It competes with everything else in the JVM that uses the common pool (parallel streams, default async CompletableFuture methods). Among valid (positive) values, --thread-count T only matters as 1 versus not 1; 0 or a negative value makes the ForkJoinPool constructor throw IllegalArgumentException.
  • 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.

Minimal repro

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.

TestBpVectorReordererExecutor.java60 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.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);
  }
}
Command
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 and ForkJoinPool.commonPool-worker-1.

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

More files, environment, full output316 words
  • Same-package test. It calls the package-private computeValueMap(vectors, similarity, TaskExecutor), which computeDocMap calls with new TaskExecutor(executor) and which TestBpVectorReorderer also uses. The cross-model reviewer reproduced the same through public computeDocMap on an IndexWriter-written 8,192-document index.
  • How stable. invokeAll forks 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 reported cpus=1), standalone runner 1/1 -ea and 10/10 iterations -da, 10/10 separate -da JVMs, 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::run in an Executor that 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 applyGoogleJavaFormat with its comment split in two; it passes checkGoogleJavaFormat and ecjLintTest. As a regression test, use this same test.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the class is in no release
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • 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 BPIndexReorderer onto the executor; this class came four months later with the old code.
  • Its --thread-count pool 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). SerialMergeScheduler does each merge "using the current thread"; ConcurrentMergeScheduler returns 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 BPIndexReorderer from RecursiveAction to Callable tasks forked through TaskExecutor.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 old RecursiveAction code with the new TaskExecutor field.
  • The class expects it too. It carries the executor through every task and builds a --thread-count pool for the command line; neither is used.
  • Tests. TestBpVectorReorderer builds its own ForkJoinPool "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.

Suggested fix

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

B. Run the root task inside the given pool when it is a ForkJoinPool

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.

C. Document the current behaviour

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), Elasticsearch, OpenSearch, OpenSearch k-NN, Solr, mailing lists, JIRA, web2026-11-01two automated prior-art passes (51 and 57 queries: class names, RecursiveAction, commonPool, ForkJoinPool, intra-merge executor, thread-count, IndexReorderer), 12 web queriesno report
GitHub search (apache/lucene and all of GitHub)2026-11-01, re-checked 2026-11-0214 manual queries; every issue and PR updated since 2026-10-29no report
Open apache/lucene PRs2026-11-01changed files of all 410 open PRs#15430 moves the class unchanged; #16847, #15285, #16571 touch other code
Related threads2026-11-01comments and reviews of #13289, #14097, #14110, #14117, #13565, #15219, #15430, #15606, #14758, #15003, #16729, #14066, #15684, #12568none discusses the executor being unused

History

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 added BpVectorReorderer and the IndexReorderer interface, and changed the BPReorderingMergePolicy constructor's parameter type from BPIndexReorderer to IndexReorderer, in one commit; git blame puts every defect line on it. CHANGES.txt lists GITHUB#14097 under 11.0.0 New Features.
  • Context: #13289 (ff8b81afc59, 2024-09-12) had moved BPIndexReorderer from RecursiveAction to Callable tasks and TaskExecutor.invokeAll; before that, BPIndexReorderer submitted its root task to the given pool, so its forks stayed there.
  • Later commits to the file, b7c7fe064ce (#14117) and 54283150a81 (#16255, 2026-06-14), do not touch the defect lines. Upstream main f815e679bf6 (2026-11-01) and branch_11x have the same file. It can be fixed before 11.0.0 ships.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails (gradle and the standalone runner, assertions on and off)
main ea57360912ed, 7cecf795236c affectedfirst repro fails
02a09f6ab12 (#14097) introducing commitfirst repro fails with -ea and -da
parent 72d93de50c2 not affectedclass absent (compile error)
10.6.0, 10.5.2, 10.2.0, 10.1.0, 9.12.3 not affectedclass absent (compile error); branch_10_6 and branch_10x by reading
upstream main f815e679bf6, branch_11x by readingsame file, no newer commit

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
first finder (misc reorder/split unit) leadRead the executor plumbing after a determinism differential; JUnit recording the reading threads.
finder (judge unit) reproduced, LowOne 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 realOwn 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, LowMinimal test fails -ea/-da, 10/10 -da JVMs, passes with the fix.
credibility audit confirmedFresh-seed re-run fails; minimal impact: a consistency fix.
this doc's gradle validation fails on stock, passes with fix AScratch 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.

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

Finding record

LFV-1070, snapshot of 2026-11-06

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, with TestBpVectorReorderer, TestBPIndexReorderer, TestBPReorderingMergePolicy incl. nightly, all of lucene/misc, checkGoogleJavaFormat and 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 commit 02a09f6ab12, 7cecf795236c and ea57360912ed; the parent 72d93de50c2, 9.12.3, 10.1.0, 10.2.0, 10.5.2 and 10.6.0 do not have the class. Upstream main f815e679bf6 (2026-11-01) is unchanged at the fix site.

Lucene and upstream sources

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-682
  • lucene/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-945
  • lucene/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-5558
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:133-138
  • lucene/core/src/java/org/apache/lucene/index/SerialMergeScheduler.java:21; lucene/core/src/java/org/apache/lucene/index/ConcurrentMergeScheduler.java:275-283, :939-945, :971
  • lucene/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:32
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:321-322
  • lucene/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-283
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:158-191, :831-832
  • lucene/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:945
  • lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:160-162 (null executor never forks)
History
  • lucene/CHANGES.txt:164

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

Repro files

About the diagrams

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.