All findings · Lucene finding · LFV-640 · checkindex-concurrent-exorcise-reorders-segments-by-size

CheckIndex -exorcise with more than one thread writes the surviving segments in size order, not commit order

When CheckIndex repairs an index with more than one thread, the default on multi-core machines, it writes the surviving segments smallest first instead of in their original order. A one-thread repair keeps the order; because commits are usually largest (oldest) first, the parallel repair typically writes an insertion-ordered index reversed.

Summary

Priority
Low, rank 753Concurrent CheckIndex -exorcise permutes surviving segment order only during a rare repair of an already broken index and no API promises that order.
Impact
minor
Since
8.11.0 and 9.0.0 (#128, merged 2021-09-01)introduced 2021-08-31 · about 5 years
Affected
8.11.0 to 10.6.0 and main; repairs with more than one thread (the default on multi-core machines)
Status
documented
Found by
non-formal: an executor-behaviour differential sweep, then a JUnit

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

Since 8.11.0 and 9.0.0 CheckIndex checks segments on several threads by default. To print small segments first it sorts them by file size, and the results it hands to the repair stay in that sorted order.

Background in three steps

  1. An index is a list of segments in a commit (segments_N). A document's id is the number of documents in the segments before its own plus its position inside the segment, so the segment order is the document order.
  2. CheckIndex verifies every segment. With -exorcise (or exorciseIndex()) it writes a new commit that keeps the good segments and drops the broken ones.
  3. Since 8.11.0 and 9.0.0 the segments can be checked in parallel. The thread count defaults to the number of CPU cores; -threadCount 1 checks them one after another.
Diagram of the repro: segments _0 (10 documents), _1 (1 document, damaged) and _2 (1 document). The one-thread repair writes [_0, _2]; the two-thread repair checks them in size order _1, _2, _0 and writes [_2, _0], so the last document added comes first.
The repro's three segments through a one-thread and a two-thread repair: the size order of the parallel check ends up in the new commit. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You add 10, 1 and 1 documents (numbered 0 to 11 in insertion order) in three commits, so the index has segments _0, _1 and _2, in that order. Then _1's stored-fields file gets damaged.
  2. You repair it with CheckIndex -exorcise, or checkIndex() then exorciseIndex(), on a machine with 2 or more cores, where the thread count defaults to the number of processors the JVM reports.
  3. You expect what a one-thread repair writes: _1 dropped, the rest in order, [_0, _2], so Sort.INDEXORDER returns ids 0-9, then 11.
  4. Instead the new commit is [_2, _0]: the parallel check sorted the segments by file size so small ones print first, and the repair writes that sorted list. The last document added, id 11, is now first.
  5. Nothing is lost beyond _1; only the order changes. With commits of 40, 10, 25 and 5 documents of similar size (a 100-character stored body each), the default command line writes [_3, _2, _0], fully reversed: Sort.INDEXORDER's top 5 become ids 75-79 instead of 0-4.

Who is affected

Anyone who repairs a damaged index with CheckIndex -exorcise, Luke's repair, or checkIndex() plus exorciseIndex() on a machine with 2 or more cores, or with an executor. It matters to applications that rely on insertion order: Sort.INDEXORDER, the order of tied hits, LogMergePolicy. Nothing is lost beyond the broken segments, and repairs are rare emergency actions.

Mechanism, terms and numbers449 words

CheckIndex -exorcise repairs a damaged index by writing a new commit that keeps the good segments and drops the broken ones. Since 8.11.0 and 9.0.0 the check runs on several threads by default (threadCount = the number of cores). In that mode it sorts the segments by file size so that small ones print first, collects the results in that sorted order, and exorciseIndex commits that list as it is. A one-thread repair keeps the commit order. The repaired index has the right documents, deletes and values, but the order of whole segments, and so of all documents, depends on the thread count: commits are usually largest (oldest) first, so an insertion-ordered index typically comes back reversed and Sort.INDEXORDER returns the newest documents first.

The code (main 645d4fe14a20, CheckIndex.java)

  1. Default is concurrent. threadCount = Runtime.getRuntime().availableProcessors() (:541); checkIndex(List) creates a fixed pool whenever threadCount > 1 and calls the executor overload (:588-600).
  2. The sequential branch keeps commit order. With no executor it walks lastCommit.info(i) for i = 0..n-1 and calls processSegmentInfoStatusResult (:823-845).
  3. The concurrent branch sorts by size. It copies the commit into segmentCommitInfos (:850-854), sorts the copy by sizeInBytes() ascending (:856-869, commented "smaller segment tends to finish faster, and hence its output can be printed out faster"; a size that throws IOException compares as equal) and submits the largest first (:871-896).
  4. Results are collected in the sorted order. The result loop walks segmentCommitInfos.get(i) (:898-931) and calls processSegmentInfoStatusResult (:929), which appends to Status.segmentInfos and, for a good segment, result.newSegments.add(info.clone()) (:975-985).
  5. The repair writes that list. exorciseIndex calls newSegments.changed() and newSegments.commit(dir) (:4606-4614), with no reordering.

Only the position of whole segments in segments_N changes, and with it the starting doc id of every segment that moves. Once this branch runs the order does not depend on timing: even a single-thread executor reorders. Each surviving segment keeps its deletes, soft deletes and doc-values updates.

The example

In the repro's index the three commits of 10, 1 and 1 documents give _0 (787 bytes), _1 and _2 (751 bytes each). The size sort gives [_1, _2, _0] (a stable sort keeps _1 before _2). With _1 damaged (the Sort.INDEXORDER column numbers the documents 0 to 11 in insertion order, as the fact probe does):

Status.segmentInfosrepaired commitSort.INDEXORDER ids
threadCount 1_0, _1 (broken), _2[_0, _2]0-9, 11
threadCount 2_1 (broken), _2, _0[_2, _0]11, 0-9

The concurrent check also prints 1 of 3: name=_1, 2 of 3: name=_2, 3 of 3: name=_0: its progress labels use the sorted position (cosmetic).

Exposure and likelihood300 words
  • Who. The CheckIndex command line with -exorcise ("When not specified, this will default to the number of CPU cores"); Luke's check and "Try to Repair" (a default CheckIndex, then exorciseIndex on the status it returned); any caller of checkIndex() plus exorciseIndex() with the default thread count or setThreadCount(n > 1), or of checkIndex(null, executor) with any executor (a repair needs a full check: exorciseIndex rejects a partial status); and downstream shard-repair commands that call checkIndex(null) without setThreadCount (read in their source, not run).
  • Default vs opt-in. Default whenever Runtime.getRuntime().availableProcessors() is more than 1, so on any multi-core host. -threadCount 1, or a JVM that reports one processor, is sequential and keeps the order.
  • Scale. Independent of index size. Affected: indexes with more than one surviving segment whose commit order differs from ascending sizeInBytes() order. That is the usual case, because commits are usually largest (oldest) first, so the ascending sort typically reverses them. Observed in the intended-behaviour check: an insertion-ordered LogDocMergePolicy index _13(108) _17(12) _1b(12) _1c(4) _1d(1) with _17 broken comes back [_1d, _1c, _1b, _13] instead of [_13, _1b, _1c, _1d] (that run did not log byte sizes).
  • What changes. Sort.INDEXORDER results, the order of equal-score hits (ties go by doc id), and which segments LogMergePolicy later treats as adjacent. Concurrent and sequential repairs of the same index write different commits, and the result depends on the machine's core count.
  • What does not. No document is lost beyond the broken segments, none is added or altered, deletes, soft deletes and doc-values updates are kept, and the repaired index checks clean.
  • How likely. Rare: -exorcise is an emergency action on an index that already has a broken segment, and only applications that rely on relative document order notice. The recorded searches found no report since 8.11.0 (2021).

Minimal repro

One JUnit test on the public API: three commits of 10, 1 and 1 stored documents (default codec, no compound files, NoMergePolicy), then _1.fdt is overwritten with zeros. A CheckIndex with setThreadCount(2) runs checkIndex() and exorciseIndex(), and the test reads the segment names of the new commit.

TestCheckIndexExorciseSegmentOrder.java (one test, public API)65 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.index;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestCheckIndexExorciseSegmentOrder extends LuceneTestCase {

  public void testConcurrentExorciseKeepsCommitOrder() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc =
          new IndexWriterConfig()
              .setCodec(TestUtil.getDefaultCodec())
              .setUseCompoundFile(false)
              .setMergePolicy(NoMergePolicy.INSTANCE);
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int numDocs : new int[] {10, 1, 1}) {
          for (int i = 0; i < numDocs; i++) {
            w.addDocument(List.of(new StoredField("f", i)));
          }
          w.commit();
        }
      }
      int length = (int) dir.fileLength("_1.fdt");
      dir.deleteFile("_1.fdt");
      try (IndexOutput out = dir.createOutput("_1.fdt", IOContext.DEFAULT)) {
        out.writeBytes(new byte[length], length);
      }

      try (CheckIndex checker = new CheckIndex(dir)) {
        checker.setThreadCount(2);
        checker.exorciseIndex(checker.checkIndex());
      }

      List<String> segments = new ArrayList<>();
      for (SegmentCommitInfo info : SegmentInfos.readLatestCommit(dir)) {
        segments.add(info.info.name);
      }
      // expected: _1 dropped, _0 and _2 kept in commit order; actual: [_2, _0] (size order)
      assertEquals(List.of("_0", "_2"), segments);
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCheckIndexExorciseSegmentOrder -Ptests.seed=DEADBEEF
Expected
[_0, _2]: the broken _1 is removed and the others keep commit order, as with setThreadCount(1).
Observed
expected:<[_0, _2]> but was:<[_2, _0]>, with assertions on and off.

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

More files, environment, full output301 words

Deterministic, single test thread; setThreadCount(2) makes the result independent of the machine (the default, the number of cores, behaves the same on any multi-core host). The default codec is set because LuceneTestCase randomizes it and the test names _1.fdt; no compound files so _1.fdt is a real file; NoMergePolicy keeps three segments. The single assertion also shows that the damage worked, because _1 is absent.

It asserts only the segment order, not doc ids. The same test is the suggested regression test: it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the recommended fix: passes. The same order is seen on 8.11.0 to 10.5.2 with the follow-up's version-portable history probe (executed); the minimal test itself fails the same way on 9.1.0 and 10.6.0 (release jars, executed 2026-10-31) and needs 9.1.0 or later to compile
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestCheckIndexExorciseSegmentOrder.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCheckIndexExorciseSegmentOrder -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false

Observed output

# gradle, stock main 645d4fe14a20, seed DEADBEEF, assertions on (trimmed)
TestCheckIndexExorciseSegmentOrder > testConcurrentExorciseKeepsCommitOrder FAILED
    java.lang.AssertionError: expected:<[_0, _2]> but was:<[_2, _0]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6E1F4422DE9E97A8]:0)
        ...
        at org.apache.lucene.index.TestCheckIndexExorciseSegmentOrder.testConcurrentExorciseKeepsCommitOrder(TestCheckIndexExorciseSegmentOrder.java:62)
:lucene:core:test (FAILURE): 1 test, 1  failure

# fact probe on an index of the same shape whose documents store their insertion number as `id` (prints only, not the repro; abridged)
threadCount=1  status=[_0, _1 (broken), _2]  repaired=[_0(10), _2(1)]  INDEXORDER ids=[0..9, 11]
threadCount=2  status=[_1 (broken), _2, _0]  repaired=[_2(1), _0(10)]  INDEXORDER ids=[11, 0..9]

# command-line test (4 commits of 40/10/25/5 docs with a 100-character stored body each, the 10-doc segment broken), assertions off
-threadCount 1          after=[_0(40), _2(25), _3(5)]  Sort.INDEXORDER top5=[0, 1, 2, 3, 4]
-threadCount 2          after=[_3(5), _2(25), _0(40)]  Sort.INDEXORDER top5=[75, 76, 77, 78, 79]
default (32 cores)      after=[_3(5), _2(25), _0(40)]  Sort.INDEXORDER top5=[75, 76, 77, 78, 79]

Why it looks unintended

  • The thread count is documented only as a speed-up: -threadCount 1 means "performed sequentially", and the executor overload is "to speed up the check".
  • The size sort's only stated purpose is printing: small segments finish first, "hence its output can be printed out faster".
  • Lucene warns where tools may reorder documents (IndexUpgrader, TieredMergePolicy); CheckIndex only promises to remove broken segments.
The full argument314 words
  • The thread count is a speed knob. CheckIndex.java:532 "Set threadCount used for parallelizing index integrity checking."; :621 "This method allows caller to pass in customized ExecutorService to speed up the check."; usage :4845-4847 "When '-threadCount 1' is used, index checking will be performed sequentially." The originating issue LUCENE-9662 (#10701) asked for concurrency so the check finishes faster.
  • Exorcise only removes. Class javadoc :104-105 "write a new segments file that removes reference to problematic segments"; exorciseIndex :4603-4604 "effectively removing all documents in broken segments from the index"; :4657-4659 "With -exorcise, this tool will remove any segments that have issues and write a new segments_N file."
  • The sort is for printing and scheduling. The code comment (:856-857) and the #128 review, where the author wrote that following the sequential order "is not really needed, especially under concurrent setting", about the printed output. No discussion found in #128, #10701, the 8.x backport or #281 identifies the sort's effect on newSegments or on the order of the repaired commit (exorcise appears there only in pasted tool output and in #281's copy of the command-line usage).
  • Reordering tools warn; CheckIndex does not. IndexUpgrader.java:48-52: "If your application relies on \"monotonicity\" of doc IDs (which means that the order in which the documents were added to the index is preserved), do a full forceMerge instead", and :77 "WARNING: This tool may reorder document IDs!"; UpgradeIndexMergePolicy.java:44-48; TieredMergePolicy.java:46-47 "this policy freely merges non-adjacent segments; if this is a problem, use LogMergePolicy"; ParallelCompositeReader.java:36-44 recommends LogDocMergePolicy "as this one does not reorder documents during merging (like TieredMergePolicy)".
  • History. Before 424192e1704 one loop walked the commit in order and filled newSegments; splitting it into two branches changed the concurrent result order as a side effect.
  • Tests. No Lucene test calls exorciseIndex. TestUtil.checkIndex by default runs the concurrent check with 2-5 threads but never exorcises, so nothing would have caught it.
The devil's-advocate check303 words

The lenses of an independent reviewer from another model family (OpenAI GPT-5.6) returned "not real", high confidence, after reproducing the reorder and agreeing with the root cause and the fix: no javadoc promises any order for Status.segmentInfos (public), Status.newSegments (package-private) or the repaired commit; CheckIndex is @lucene.experimental; doc numbers are "ephemeral" (IndexReader.java:51-54) and docids "internal implementation" (index/package-info.java:101-105); the default TieredMergePolicy already merges non-adjacent segments; and exorcise renumbers every document after a dropped segment anyway.

Why it fails: the ephemeral-docid text is about absolute numbers changing as documents are added, deleted and merged. Relative insertion order is something Lucene treats as relied upon: it names it ("monotonicity"), warns in the tools that may break it (IndexUpgrader, UpgradeIndexMergePolicy, TieredMergePolicy), and sends users who need it to LogMergePolicy, whose users then get a reversed index from exorcise with no warning. And two execution modes, documented only as a faster and a sequential way to run the same check, write different commits from the same input; that equivalence is an implied contract, not a written one. Concession: maintainers may land a fix as a consistency improvement rather than a bug fix (the analogous IndexSearcher parity change GITHUB#12516 was filed under "Changes in runtime behavior"). The hunt's verdict was "confirmed", medium confidence, after a "disputed" judge.

The core of the case is that the repaired index depends on -threadCount, with Sort.INDEXORDER reversed, not that doc ids change; no javadoc promises the order, and the Status.segmentInfos order alone is not a bug. Likely objections are the ephemeral-docid javadoc and #7405 ("Remove checkindex -exorcise"), and (the fact check adds) that readers can already order leaves differently (IndexWriterConfig#setLeafSorter, DirectoryReader.open(dir, leafSorter)): that is opt-in and explicit, while the reorder here is a side effect of the thread count.

Suggested fix

Recommended Record the results in commit order; keep size-ordered scheduling and printing

Keep the size-ordered scheduling and printing, but record the results in commit order after the result loop, as the sequential branch does (+11/-1 lines in CheckIndex.java). The repro then passes, and so do the 1,396 tests of the core index package, with assertions on and off.

      Map<SegmentCommitInfo, Status.SegmentInfoStatus> statuses = new IdentityHashMap<>();
      for (int i = 0; i < numSegments; i++) {
        // ... unchanged: wait for futures[i], print its output in size order ...
        statuses.put(info, segmentInfoStatus); // was: processSegmentInfoStatusResult(...)
      }

      // record the results in commit order, as the sequential branch does
      for (SegmentCommitInfo info : lastCommit) {
        if (statuses.containsKey(info)) {
          processSegmentInfoStatusResult(result, info, statuses.get(info));
        }
      }
All options and trade-offs4 options ยท 504 words

Option A was built and checked with gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, 2026-10-31); B was checked by the tie-breaker on a9779ef3c193, whose CheckIndex.java is identical. C and D were not run.

Record the results in commit order; keep size-ordered scheduling and printing (validated by the checks) (recommended)

In the concurrent branch, keep each status in an identity map instead of recording it at once, then walk the commit and record the results in its order. +11/-1 lines (one import, the map, the final loop).

      Map<SegmentCommitInfo, Status.SegmentInfoStatus> statuses = new IdentityHashMap<>();
      for (int i = 0; i < numSegments; i++) {
        // ... unchanged: wait for futures[i], print its output in size order ...
        statuses.put(info, segmentInfoStatus); // was: processSegmentInfoStatusResult(...)
      }

      // record the results in commit order, as the sequential branch does
      for (SegmentCommitInfo info : lastCommit) {
        if (statuses.containsKey(info)) {
          processSegmentInfoStatusResult(result, info, statuses.get(info));
        }
      }

Trade-offs

  • newSegments and Status.segmentInfos match the sequential branch; #128's deliberate print and scheduling order stay.
  • One map entry per segment. SegmentCommitInfo does not override equals, so an identity map is exact.
  • Segments skipped by onlySegments are never put, so they stay skipped; that only affects Status.segmentInfos, since exorciseIndex rejects a partial status. The interrupted-caller path is unchanged (a null status is still passed on; that is a separate bug in the same loop).

What the checks validated

  • Stock: the repro fails (gradle, assertions on). With A it passes with assertions on and off.
  • TestCheckIndex, TestAllFiles*, TestSwappedIndexFiles, TestSegmentInfos, TestCodecHoldsOpenFiles and the repro: 26 tests, 0 failures, assertions on and off.
  • org.apache.lucene.index.*: 1,396 tests, 56 skipped, 0 failures, assertions on and off (seed DEADBEEF); 22 of these test classes call TestUtil.checkIndex, which by default runs the concurrent check with 2-5 threads.
  • checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass for lucene:core.
  • The tie-breaker's command-line test (-threadCount 2 and the default) passes 3/3 with A and fails 2/3 without, assertions off. An equivalent variant (an array plus a map of sorted positions) passed every test on a9779ef3c193.

Collect and print in commit order (validated by the checks)

Walk the commit and look up each segment's future (the finder's fix).

Trade-offs

Fixes the order, but moves the per-segment log output back to commit order, which #128 changed on purpose ("so that the smaller ones finishing faster should be printed out earlier"); reviewers may object.

What the checks validated

By the tie-breaker on a9779ef3c193: the finder's, the independent reproduction's, the intended-behaviour check's and the command-line tests pass, and TestCheckIndex 9/9.

Reorder in exorciseIndex (not run)

Rebuild newSegments in the commit's order before committing.

Trade-offs

Fixes the written index only; Status.segmentInfos stays size-ordered, and exorciseIndex would have to re-read the commit (Status keeps only segmentsFileName) or newSegments would have to carry each segment's position.

Document the size order (not recommended)

Say that with threadCount > 1, -exorcise writes the segments in ascending size order.

Trade-offs

Keeps two execution modes that write different indexes from the same input.

Downstream note

Downstream shard-repair commands (Elasticsearch's and OpenSearch's RemoveCorruptedLuceneSegmentsAction) call checkIndex(null) without setThreadCount, so on multi-core hosts they also write the size-ordered repair (read in their source, not run).

How it was found

An executor-behaviour sweep ran 13 Lucene APIs with 9 kinds of executor and compared each result with the call without an executor. CheckIndex's per-segment results came back in another order in every cell; reading exorciseIndex showed that the repair writes that order, and a JUnit with one damaged segment confirmed it.

The method in full119 words

Method: non-formal (a differential sweep over a hand-picked set of executor behaviours is testing, not bounded-exhaustive checking; the finder tagged it semi-formal).

  • A hunt unit (executor pluggable-behaviour sweep) ran a harness over 13 API surfaces and 9 executor behaviours on the stock jars, comparing each call with the executor-less call (same result fingerprint expected).
  • The CheckIndex.checkIndex rows differed in every non-rejecting cell (caller-runs, single thread, LIFO pool, interrupted pool thread): on a clean 5-segment index Status.segmentInfos came back _1,_3,_2,_0,_4 instead of _0 to _4.
  • The finder then read exorciseIndex, saw that it commits the same list, and wrote a public-API JUnit with one corrupted segment, which fails with assertions on and off while the sequential control passes.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (with the migrated JIRA archive), JIRA, the mailing lists, downstream trackers or the web, searched 2026-10-27 and refreshed on GitHub 2026-10-31. The introducing PR's review discussed only the order of printed output.

Searches2 searches
  • Closest items, none a report of this bug: #128 / #10701 (LUCENE-9662), lucene-solr#2567 and #281 (the introducing change, its backport and the default thread count; the review covered printed output only); #10004 (salvaging ids of documents in dropped segments); #7405 (open 2015 proposal to remove -exorcise, a likely objection); #7820, #16474, #16476, #12872 (exorcise of an unreadable .si or commit point); #16865 (sizeInBytes caching, the sort key, no CheckIndex change); elastic/elasticsearch#31389 (shard-repair tool feature request, no order mention).
  • Not fixed upstream: the newest commit on upstream main's CheckIndex.java is #16476 (2026-10-05), which does not change the ordering; no open PR changes the concurrent sort or result loop.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the JIRA archive, apache/lucene-solr, apache/solr, Lucene.NET, two downstream engines), JIRA (LUCENE, SOLR), dev@, java-user@, issues@ and commits@, web2026-10-27a scripted prior-report search run of 70 queries with 0 errors, two more runs (43 and 40 queries), 33 manual GitHub, 6 manual JIRA and 10 manual list queries; every comment and review of #128, #10701, lucene-solr#2567, #281 and the LUCENE-9662 record; the changed files of all 409 open apache/lucene PRs (10 touch CheckIndex.java)no report; the related items above, all a different matter
GitHub (apache/lucene issues, PRs and commits)2026-10-31"CheckIndex exorcise", "CheckIndex threadCount", "CheckIndex segment order", "exorcise order", "CheckIndex concurrent" updated since 2026-10-26; all "CheckIndex" items updated since 2026-10-25; "exorciseIndex"; the latest commits on CheckIndex.javano report; #16474, #16475, #16480 and #16891 were updated but are not about order; no new commit on CheckIndex.java since #16476

History

Introduced by #128 (424192e1704, merged 2021-09-01; 8.x backport lucene-solr#2567), which made the check concurrent and added the size sort. First released in 8.11.0 and 9.0.0; every release since is affected and it was never fixed. 8.10.1 keeps commit order.

Versions tested6 rows

Introduced by 424192e1704 (apache/lucene#128, "LUCENE-9662: CheckIndex should be concurrent - parallelizing index check across segments", committed 2021-08-31, merged 2021-09-01). Before it, one loop walked the commit in order and filled newSegments; the commit split it into a sequential branch (commit order) and a concurrent branch that sorts a copy by sizeInBytes() and records the results in that order. exorciseIndex was not changed. The 8.x backport is 965b54ce3fd (apache/lucene-solr#2567, 2021-09-10). Follow-up 7f8607b59e0 (#281) made the default thread count all cores instead of min(cores, 4); both defaults are concurrent on any multi-core host. Later commits on these lines (#637, #12530, #12876, #12979) only made printing null-safe, renamed a variable, replaced Collections.sort with List.sort and used StandardCharsets.UTF_8.

8.10.1 and 8.11.0 build the same index with identical segment sizes, and only 8.11.0 reorders, so the change is in CheckIndex alone. The 2021 main snapshots themselves were not built.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails (gradle, assertions on; standalone runner, on and off); passes with the recommended fix
main a9779ef3c193 affectedfive independent tests fail with assertions on and off; the sequential controls pass
10.6.0 affectedthe minimal test fails (release jars, assertions off, 2026-10-31); same size sort and result loop (CheckIndex.java:822, :893)
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedevery concurrent mode reorders; -threadCount 1 keeps the order (release jars)
9.0.0, 8.11.0-8.11.4 first releasesthe command line and callers that set an infoStream reorder; a programmatic concurrent check with no infoStream throws a separate, known NPE first (#637, fixed in 9.1.0)
8.10.0, 8.10.1 not affectedno concurrent check; the default API and -exorcise keep commit order

Independent checks

An independent reproducer, a devil's-advocate verifier, an adjudication, a tie-breaker and a credibility audit all reproduced it. The independent reviewer from another model family (OpenAI GPT-5.6) also reproduced it and agreed on the root cause and fix, but argued not a bug because no API promises the order; the tie-breaker confirmed it, medium confidence.

Checks and verdicts303 words

Every run is deterministic (a single-thread executor is enough, so no timing is involved). The disagreement is about the contract, never about the facts.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit, executor sweep, then JUnit) candidate4 flushed segments, one byte flipped: the repaired commit is [_1, _3, _0] instead of [_0, _1, _3]; fails with assertions on and off on main, 10.5.2, 9.12.3 and 9.1.0; the sequential control passes.
Refute and intended-behaviour reviews (OpenAI GPT-5.6, executed) not real, high confidenceEach wrote and ran its own test and reproduced the reorder, agreed with the root cause and the fix, but argued that no API promises the order and doc ids are ephemeral.
Independent reproducer real, Low, weak implied contract5 segments of distinct sizes: [_4, _2, _3, _0] instead of [_0, _2, _3, _4], with setThreadCount(4) and a single-thread executor; also 10.5.2, 9.12.3 and 8.11.2; passes with the fix.
Intended-behaviour devil's advocate real, mediumAn insertion-ordered LogDocMergePolicy index comes back fully reversed; weighed the no-contract, ephemeral-docid, TieredMergePolicy, #128-review and remove-exorcise arguments.
Adjudication disputed, mediumRe-ran the independent reproduction's test (fails 3 of 4, assertions on and off; passes with the fix). Facts certain; bug status depends on an implied contract.
Tie-breaker confirmed, medium, minor/LowAdded the command-line test with the default configuration (-threadCount 2 and the default reverse the commit); two fixes pass all tests; upstream TestCheckIndex passes 9/9 with either.
Credibility audit keep, minorRe-ran the command-line and the intended-behaviour check tests on main with fresh seeds; the code read matches the root cause.
Minimal repro and this doc fails on 645d4fe14a20; the fix passesOne test, verified 2026-10-29 and re-run 2026-10-31 (standalone runner and gradle), with the fix validated by gradle.

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

Finding record

LFV-640, snapshot of 2026-11-06

Registry title
CheckIndex -exorcise with more than one thread (threadCount > 1, the default on multi-core machines, or checkIndex(onlySegments, executor)) writes the surviving segments in ascending file-size order instead of commit order, so the repaired index's segment order and relative document order depend on the thread count
Area
index / CheckIndex concurrent segment check and exorcise
Priority
Low, rank 753: Concurrent CheckIndex -exorcise permutes surviving segment order only during a rare repair of an already broken index and no API promises that order.
Impact class
minor
Impact note
Operators and applications that repair an index with a broken segment through CheckIndex -exorcise (the command line, Luke's 'Try to Repair', or checkIndex() followed by exorciseIndex; downstream shard-repair commands call checkIndex(null) without setThreadCount) on any host with 2 or more CPUs, where threadCount defaults to availableProcessors, get a valid repaired index with exactly the documents, deletes, soft deletes and doc-values updates a sequential run keeps, but with the surviving segments in ascending sizeInBytes order instead of commit order; commits are usually roughly largest (oldest) first, so an insertion-ordered index typically comes back reversed, which changes Sort.INDEXORDER results, the order of equal-score hits and which segments LogMergePolicy later treats as adjacent; nothing is lost or altered beyond the broken segments, doc ids are documented as ephemeral, and no API promises the order, so the visible defect is that sequential and concurrent repairs of the same index write different commits; any index size; every release since 8.11.0 [exec].
Summary
You repair an index that has a broken segment with CheckIndex -exorcise, or checkIndex() followed by exorciseIndex, on a multi-core machine, where threadCount defaults to the number of cores. You expect the same repaired commit that a sequential run (-threadCount 1) writes: the documentation describes the thread count and the executor overload only as ways to speed up the check, and exorcise only as removing the broken segments. Instead the concurrent path collects the segment results in ascending file-size order (a sort added so that small segments print first) and exorciseIndex commits that list as it is, so the surviving segments, and with them the relative order of their documents, are permuted; an index written in insertion order comes back reversed, so Sort.INDEXORDER returns the newest documents first instead of the oldest.
Introduced
424192e1704664dc0ebc55109feaad5990b945cb on main, 'LUCENE-9662: CheckIndex should be concurrent - parallelizing index check across segments (#128)' (apache/lucene#128 by GitHub zacharymorn, reviewed by dweiss, mikemccand and rmuir; issue LUCENE-9662 = apache/lucene#10701); 8.x backport 965b54ce3fd450dd42758244870b6228e1b5e5d4 on branch_8x (apache/lucene-solr#2567). It replaced the single commit-order loop that filled Status.segmentInfos and newSegments with a sequential branch (commit order) and a concurrent branch that sorts a copy of the commit by sizeInBytes and appends the results in that sorted order; exorciseIndex commits newSegments unchanged [read: parent 424192e1704^ CheckIndex.java:608-810 has one commit-order loop; git pickaxe shows only 37cca614fc2 (#12876, Collections.sort -> List.sort) touched the sort since] [exec: the release pair 8.10.1 (keeps order) / 8.11.0 (reorders) brackets the 8.x backport on an index with identical segment sizes; the 2021 main snapshots were not built]. Follow-up 7f8607b59e034541d62687374b86197797a30a4f (#281) changed the default threadCount from min(cores, 4) to cores (2021-08-31 19:24 -0700 (commit) / 2021-09-01 UTC (merge) on main; 2021-09-10 on branch_8x (backport))
First affected version
8.11.0 [exec] (8.x line, through the backport lucene-solr#2567) and 9.0.0 [exec] (first main-line release); 8.10.1 and older are not affected [exec 8.10.0 and 8.10.1: no concurrent check, so the default API and the CLI -exorcise keep commit order]
Affected versions
every release from 8.11.0 through 10.6.0, and main a9779ef3c193 and 645d4fe14a20; never fixed. [exec] the follow-up's version-portable history probe (public API and CLI, default and explicit thread counts, an executor, a sequential control) reorders on 8.11.0 (-ea and -da), 8.11.1, 8.11.2, 8.11.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da), and keeps commit order with -threadCount 1 everywhere; the independent reproduction's JUnit fails on main, 10.5.2 and 9.12.3 (and its main() variant on 8.11.2); the intended-behaviour check's LogMergePolicy test and the GPT reviewer's own test fail on main and 10.5.2; the tie-breaker's command-line test fails on main, 10.5.2 and 9.12.3; the minimal test fails on main 645d4fe14a20 (-ea and -da) and on the 9.1.0 and 10.6.0 release jars (-da), 2026-10-31. [read] git tag --contains 424192e1704 lists every release tag from 9.0.0 to 10.6.0; CheckIndex.java on upstream main, branch_10x and branch_10_6 still sorts and appends in size order (contents API, 2026-10-27). Version note: in 8.11.0-8.11.4 and 9.0.0 the programmatic concurrent path with no infoStream first throws a NullPointerException (a separate known issue fixed by #637 in 9.1.0 and never backported to 8.11.x); the command line and callers that set an infoStream hit the reorder there [exec].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it, an adjudication that rated it disputed and a tie-breaker that confirmed it (medium confidence), 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-10-27, read-only; the prior-report record verdict new): a scripted prior-report search with 70 queries, 0 errors (30 GitHub issue/PR, 5 commit, 5 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 6 web), plus the finder's 43-query run and the intended-behaviour check's 40-query run, 33 manual GitHub searches (apache/lucene, apache/lucene-solr, apache/solr, Lucene.NET and two downstream engines), 6 manual JIRA queries (LUCENE and SOLR), 10 manual list queries, a file-level scan of all 409 open apache/lucene PRs (10 touch CheckIndex.java, none changes the concurrent sort/collect code at CheckIndex.java:850-990) and a read of every comment, review comment and review of #128, #10701, lucene-solr#2567, #281 and the JIRA LUCENE-9662 record. Related, not this bug: #128 / #10701 / LUCENE-9662 and lucene-solr#2567 (the introducing change and backport; the review discussed only the order of printed output and scheduling, zacharymorn: following the sequential order 'is not really needed, especially under concurrent setting'; newSegments and exorcise never came up); #281 (default threadCount = cores, usage text); #10004 / LUCENE-8961 (open; salvaging the ids of documents in dropped segments); #7405 / LUCENE-6344 (open 2015 proposal to remove -exorcise); #7820 / LUCENE-6762, #16474 (open PR: exorcise refuses a status whose commit point could not be read), #16476, #12872 (unreadable .si or commit point); #12530; #16865 (SegmentCommitInfo.sizeInBytes caching, the sort key, no CheckIndex change; closed unmerged on 2026-10-27 by its author, who suspected their own caller was violating the concurrency contract; its issue #16861 is still open); elastic/elasticsearch#31389 (shard-repair tool feature request, no order mention). Not fixed upstream: CheckIndex.java on main, branch_10x and branch_10_6 still sorts by size and appends in that order (contents API, 2026-10-27); the newest commit touching the file is #16476 (2026-10-05), which does not change the ordering
Lucene commit tested
main 645d4fe14a20 (2026-10-31): the minimal test fails through gradle in a scratch worktree (assertions on) and through a standalone runner (assertions on and off); with the recommended fix it passes, together with the whole core index package (1,396 tests, assertions on and off) and the core format and lint checks. Earlier runs: main a9779ef3c193 (CheckIndex.java identical) with five independent tests; release jars 8.11.0-8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2 affected, and the minimal test itself fails on 9.1.0 and 10.6.0; 8.10.0 and 8.10.1 clean.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:104-105 (at a9779ef3c193) (class javadoc: 'write a new segments file that removes reference to problematic segments'), :110-111 (@lucene.experimental, back up before exorcising), :532 ('Set threadCount used for parallelizing index integrity checking'), :541 (threadCount = Runtime.getRuntime().availableProcessors()), :588-600 (threadCount > 1 creates the internal pool and calls the executor overload), :621 ('allows caller to pass in customized ExecutorService to speed up the check'), :823-845 (sequential branch, commit order), :856-869 (sort a copy of the commit by sizeInBytes; comment 'smaller segment tends to finish faster, and hence its output can be printed out faster'; comparator returns 0 on IOException), :871-896 (larger segments scheduled first), :898-931 (results consumed by sorted index), :975-985 (processSegmentInfoStatusResult appends to Status.segmentInfos and newSegments.add(info.clone())), :4598-4614 (exorciseIndex javadoc 'effectively removing all documents in broken segments' and newSegments.commit unchanged), :4655-4659 (-exorcise 'will remove any segments that have issues and write a new segments_N file'), :4845-4847 (usage: '-threadCount X ... default to the number of CPU cores. When -threadCount 1 is used, index checking will be performed sequentially'); CheckIndex.java has no diff between ea57360912ed and a9779ef3c193 (git diff)
  • lucene/luke/src/java/org/apache/lucene/luke/models/util/IndexUtils.java:302 (at a9779ef3c193) and :323-327 (Luke's check and repair use new CheckIndex(dir), the default threadCount, then exorciseIndex(st)); lucene/core/src/java/org/apache/lucene/index/IndexReader.java:51-54 and index/package-info.java:101-105 (doc numbers ephemeral, docids internal: the counter-evidence); index/IndexUpgrader.java:48-52 and :77 ('WARNING: This tool may reorder document IDs!'), index/UpgradeIndexMergePolicy.java:44-48 and index/TieredMergePolicy.java:46 (tools and policies that may reorder doc ids say so; re-read here)
  • https://github.com/apache/lucene/pull/128 (introducing PR), https://github.com/apache/lucene/issues/10701 (LUCENE-9662), https://github.com/apache/lucene-solr/pull/2567 (8.x backport), https://github.com/apache/lucene/pull/281 (default threadCount), https://github.com/apache/lucene/issues/7405 (open: remove -exorcise)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:541, (at 645d4fe14a20) :588-600, :823-845, :850-869, :871-896, :898-931, :975-985, :4606-4614
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:541, (at 645d4fe14a20) :588-600, :4608-4611 (partial status rejected), :4845-4847 (usage: default thread count)
  • lucene/luke/src/java/org/apache/lucene/luke/models/util/IndexUtils.java:299-308, (at 645d4fe14a20) :318-329
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:104-105, (at 645d4fe14a20) :110, :532, :621, :856-857, :4603-4604, :4657-4659, :4845-4847
  • lucene/core/src/java/org/apache/lucene/index/IndexUpgrader.java:48-52, (at 645d4fe14a20) :77; UpgradeIndexMergePolicy.java:44-48; TieredMergePolicy.java:46-47; ParallelCompositeReader.java:36-44; IndexWriterConfig.java:524 (setLeafSorter); DirectoryReader.java:88 (open with leafSorter); IndexReader.java:51-54; package-info.java:101-105
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:327-331; (at 645d4fe14a20) grep for exorcise over lucene/ (only CheckIndex.java and luke IndexUtils.java:327)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:144 (at 645d4fe14a20) (segmentsFileName), :169 (newSegments), :898-931; SegmentCommitInfo.java (no equals override)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and an executed probe of the real jar on the repro's index (threadCount 1, threadCount 2 and a one-thread executor).

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.