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.
- 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. CheckIndexverifies every segment. With-exorcise(orexorciseIndex()) it writes a new commit that keeps the good segments and drops the broken ones.- 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 1checks them one after another.
- You add 10, 1 and 1 documents (numbered 0 to 11 in insertion order) in three commits, so the index has segments
_0,_1and_2, in that order. Then_1's stored-fields file gets damaged. - You repair it with
CheckIndex -exorcise, orcheckIndex()thenexorciseIndex(), on a machine with 2 or more cores, where the thread count defaults to the number of processors the JVM reports. - You expect what a one-thread repair writes:
_1dropped, the rest in order,[_0, _2], soSort.INDEXORDERreturns ids 0-9, then 11. - 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. - 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.
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.
- Default is concurrent.
threadCount = Runtime.getRuntime().availableProcessors()(:541);checkIndex(List)creates a fixed pool wheneverthreadCount > 1and calls the executor overload (:588-600). - The sequential branch keeps commit order. With no executor it walks
lastCommit.info(i)fori = 0..n-1and callsprocessSegmentInfoStatusResult(:823-845). - The concurrent branch sorts by size. It copies the commit into
segmentCommitInfos(:850-854), sorts the copy bysizeInBytes()ascending (:856-869, commented "smaller segment tends to finish faster, and hence its output can be printed out faster"; a size that throwsIOExceptioncompares as equal) and submits the largest first (:871-896). - Results are collected in the sorted order. The result loop walks
segmentCommitInfos.get(i)(:898-931) and callsprocessSegmentInfoStatusResult(:929), which appends toStatus.segmentInfosand, for a good segment,result.newSegments.add(info.clone())(:975-985). - The repair writes that list.
exorciseIndexcallsnewSegments.changed()andnewSegments.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.
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.segmentInfos | repaired commit | Sort.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
CheckIndexcommand line with-exorcise("When not specified, this will default to the number of CPU cores"); Luke's check and "Try to Repair" (a defaultCheckIndex, thenexorciseIndexon the status it returned); any caller ofcheckIndex()plusexorciseIndex()with the default thread count orsetThreadCount(n > 1), or ofcheckIndex(null, executor)with any executor (a repair needs a full check:exorciseIndexrejects a partial status); and downstream shard-repair commands that callcheckIndex(null)withoutsetThreadCount(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-orderedLogDocMergePolicyindex_13(108) _17(12) _1b(12) _1c(4) _1d(1)with_17broken comes back[_1d, _1c, _1b, _13]instead of[_13, _1b, _1c, _1d](that run did not log byte sizes). - What changes.
Sort.INDEXORDERresults, the order of equal-score hits (ties go by doc id), and which segmentsLogMergePolicylater 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:
-exorciseis 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).
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.
/*
* 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);
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCheckIndexExorciseSegmentOrder -Ptests.seed=DEADBEEF
- Expected
[_0, _2]: the broken_1is removed and the others keep commit order, as withsetThreadCount(1).- Observed
expected:<[_0, _2]> but was:<[_2, _0]>, with assertions on and off.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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
# 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]- The thread count is documented only as a speed-up:
-threadCount 1means "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 onnewSegmentsor 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-44recommendsLogDocMergePolicy"as this one does not reorder documents during merging (like TieredMergePolicy)". - History. Before
424192e1704one loop walked the commit in order and fillednewSegments; splitting it into two branches changed the concurrent result order as a side effect. - Tests. No Lucene test calls
exorciseIndex.TestUtil.checkIndexby 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.
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
newSegmentsandStatus.segmentInfosmatch the sequential branch; #128's deliberate print and scheduling order stay.- One map entry per segment.
SegmentCommitInfodoes not overrideequals, so an identity map is exact. - Segments skipped by
onlySegmentsare never put, so they stay skipped; that only affectsStatus.segmentInfos, sinceexorciseIndexrejects 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,TestCodecHoldsOpenFilesand 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 callTestUtil.checkIndex, which by default runs the concurrent check with 2-5 threads.checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass forlucene:core.- The tie-breaker's command-line test (
-threadCount 2and 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 ona9779ef3c193.
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.
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.
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 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).
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.checkIndexrows differed in every non-rejecting cell (caller-runs, single thread, LIFO pool, interrupted pool thread): on a clean 5-segment indexStatus.segmentInfoscame back_1,_3,_2,_0,_4instead of_0to_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.
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.sior commit point); #16865 (sizeInBytescaching, 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.javais #16476 (2026-10-05), which does not change the ordering; no open PR changes the concurrent sort or result loop.
| Where | When | How | Found |
|---|---|---|---|
| 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@, web | 2026-10-27 | a 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.java | no report; #16474, #16475, #16480 and #16891 were updated but are not about order; no new commit on CheckIndex.java since #16476 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle, assertions on; standalone runner, on and off); passes with the recommended fix |
main a9779ef3c193 | affected | five independent tests fail with assertions on and off; the sequential controls pass |
| 10.6.0 | affected | the 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 | affected | every concurrent mode reorders; -threadCount 1 keeps the order (release jars) |
| 9.0.0, 8.11.0-8.11.4 | first releases | the 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 affected | no concurrent check; the default API and -exorcise keep commit order |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit, executor sweep, then JUnit) | candidate | 4 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 confidence | Each 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 contract | 5 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, medium | An insertion-ordered LogDocMergePolicy index comes back fully reversed; weighed the no-contract, ephemeral-docid, TieredMergePolicy, #128-review and remove-exorcise arguments. |
| Adjudication | disputed, medium | Re-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/Low | Added 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, minor | Re-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 passes | One test, verified 2026-10-29 and re-run 2026-10-31 (standalone runner and gradle), with the fix validated by gradle. |
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 464 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,646 words
- 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: maina9779ef3c193(CheckIndex.javaidentical) 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.
- 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-4847lucene/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-105lucene/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)
- TestCheckIndexExorciseSegmentOrder.java (one test, public API):
repro/LFV-640/src/org/apache/lucene/index/TestCheckIndexExorciseSegmentOrder.java(65 lines)
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).
assets/LFV-640/bug.png(464 KB)
![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.](../assets/LFV-640/bug.png)