An index sort keeps segments physically sorted so that searches sorted the same way can stop early. For an INT sort, flush and search order documents by the low 32 bits of each value; merges compare the full 64-bit value. For values outside the int range the orders differ, and the merged segment breaks its own sort.
- Sort the index by an int field,
setIndexSort(new Sort(new SortField("s", SortField.Type.INT))), and fill it withNumericDocValuesField, which accepts any long. Flush document a = 2^32 (4,294,967,296) into one segment, and b = 1 and c = 2 into another. - Flush and search read an INT sort value as
(int) value, its low 32 bits, so a counts as 0. The same three documents flushed into one segment come out a, b, c, and a top-1 sorted search returns a. - Run
forceMerge(1). You expect a, b, c. The merge compares the full longs instead (1 < 2 < 4,294,967,296) and writes b, c, a. Reverse sorts give a, c, b instead of c, b, a;SortedNumericSortField(Type.INT)breaks the same way. - CheckIndex at level 2 reports
segment has indexSort=<int: "s"> but docID=1 sorts after docID=2; level 1, the command-line default, does not look. A top-1 search sorted by the index sort trusts the order and returns b, with any hit-count threshold. - In-range values merge correctly. The misorder stays until a reindex. It started in 8.2.0, when a refactoring dropped the merge-time
(int)cast.
At risk: applications with an INT index sort (SortField or SortedNumericSortField) over a raw NumericDocValuesField or SortedNumericDocValuesField that store a long outside the int range, such as a millisecond timestamp or a 64-bit id. IntField cannot produce one. A merge goes wrong when such a value's full-long order differs from its INT order across the merged segments.
Mechanism, terms and numbers614 words
With IndexWriterConfig#setIndexSort on a SortField (or SortedNumericSortField) of type INT, three code paths order documents. Flush (IndexSorter.IntSorter#getDocComparator) and the search-time IntComparator, which CheckIndex's index-sort test also uses, compare (int) longValue(), the low 32 bits. The merge key, IntSorter#getComparableProviders, returns the full values.longValue(). NumericDocValuesField and SortedNumericDocValuesField accept any long and IndexWriter checks only the doc-values type of index-sort fields, so a value outside the int range is accepted, and for it the merge interleaves already-sorted segments by a key that can disagree with their order: the merged segment is out of order whenever the full-long and INT orders of the documents differ across the segments being merged. Segments [a = 2^32] and [b = 1, c = 2] merge to b, c, a instead of a, b, c: CheckIndex at level 2 or higher rejects the segment and searches sorted by the index sort return wrong top hits. The merge-time cast existed from 6.2.0 to 8.1.1; LUCENE-8900 (8.2.0) dropped it. Every release from 8.2.0 to 10.6.0 and main is affected (11 releases and main executed, the rest, 10.6.0 included, by code reading or branch inference); restoring the one-line cast fixes it.
- Index sort:
IndexWriterConfig#setIndexSortkeeps "all (flushed and merged) segments" sorted (IndexWriterConfig.java:504). A search sorted by the index sort, or by a prefix of it, can stop collecting a segment at the first non-competitive document (TopFieldCollector.java:59,:92-111,:152-171). - INT key: for
SortField.Type.INT, a doc value counts as(int) value, its low 32 bits, sign-extended. 2^32 = 4,294,967,296 counts as 0; 3,000,000,000 counts as -1,294,967,296. - Merge key: a merge of index-sorted segments is a k-way merge (
MultiSorter). An INT sort supplies onelongper document (throughgetComparableValues,MultiSorter.java:84), and "the natural ordering of long values" must match the sort's comparator (IndexSorter.java:57-63).
NumericDocValuesFieldandSortedNumericDocValuesFieldtake a 64-bit long. For index-sort fields IndexWriter checks only the doc-values type (IndexingChain.java:1635), not the range, so 2^32 under an INT sort is accepted.- Flush sorts with
values[docID] = (int) dvs.longValue()andInteger.compare(IndexSorter.java:249,:252). The search comparator reads(int) docValues.longValue()(IntComparator.java:81), andCheckIndex.testSortuses the search comparators (CheckIndex.java:1241). - The merge key of
IntSorter#getComparableProvidersisreturn values.longValue();with no cast (IndexSorter.java:228). The siblingFloatSorter, also a 32-bit sorter over NUMERIC doc values, narrows with(int)at merge and at flush (:363,:382). MultiSortertakes each segment's documents in their existing order and only interleaves the segments by the merge key (MultiSorter.java:35-39,:89-102,:118-136). Segment 2 is [b = 1, c = 2], segment 1 is [a = 2^32]; the keys 1 and 2 are below 4,294,967,296, so the merged segment is b, c, a. With the segments in the other order the keys already look sorted and the merge concatenates them: b, c, a again.- A search sorted by the index sort sees b first, finds c not competitive and skips the rest of the segment (
TopFieldCollector.java:92-111), so a, the INT minimum, is never compared. This happens with anytotalHitsThreshold, includingInteger.MAX_VALUE.
| check | expected | main |
|---|---|---|
| merged order, ascending | a, b, c | b, c, a |
| merged order, reverse INT sort | c, b, a | a, c, b |
merged order, SortedNumericSortField(Type.INT) | a, b, c | b, c, a |
| CheckIndex level 2 | clean | segment has indexSort=<int: "s"> but docID=1 sorts after docID=2 |
| top 1 sorted by the index sort | a | b |
| control: one flushed segment | a, b, c | a, b, c |
| control: a = 0 (in range), merged | a, b, c | a, b, c |
Exposure and likelihood358 words
- Who is at risk. Indexes with an INT index sort whose doc values hold a value outside
[Integer.MIN_VALUE, Integer.MAX_VALUE]. Such a value is necessary but not sufficient: a merge goes wrong only when the full-long and INT orders of the documents differ across the segments being merged. That takes a type mismatch: 64-bit data in a field whose sort was declared INT.IntFieldalways stores ints. The experimental columnarIndexWriter#addBatchwith aLongColumnofNumericKind.INTreaches the same misorder. - Opt-in. Index sorting is off by default, and in-range values are unaffected.
- Any merge. Background merges and
forceMergeboth go throughMultiSorter.sort, which orders such a document by its full value; whenever that differs from its INT order, the merged segment is out of order. One document and two segments suffice; deterministic with assertions on and off, ascending and reverse. - Silent at write time. No exception at
addDocument, flush, merge or commit, and flush and search agree on the low 32 bits, so the index looks right until a merge. - Persistent. The merged segment violates its declared sort until a reindex, also after a code fix. CheckIndex at level 2 or higher rejects it; level 1, the command-line default, and a
CheckIndexbuilt in code withoutsetLeveldo not run the check. Up to 8.10.x CheckIndex records the error but still reportsclean=true. - Wrong top hits. Searches sorted by the index sort, or a prefix of it, return wrong top hits with any hit-count threshold. Every hit still matches the query; document contents and deletions are intact.
- Rare. No report in about seven years; the registry rates likelihood rare.
- Not checked: whether
IndexSortSortedNumericDocValuesRangeQuery, which reads(int) topValueand relies on segment order, returns a wrong hit set on such a segment. Also not checked: the skip-index path of sorted search. For a field with a doc-values skip index (NumericDocValuesField.indexedField, which also accepts any long),NumericComparatorcan build a competitive iterator whose INT bounds come from the int bottom value but are compared with the skipper's raw long block ranges, without narrowing; whether that skips competitive documents with out-of-range values was not run.
The test shows that under an INT index sort, merging segments [a = 2^32] and [b = 1, c = 2] orders documents by the full 64-bit value, while flush and the search-time comparator use the low 32 bits ((int) 2^32 = 0).
/*
* 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestIntIndexSortMergeOrder extends LuceneTestCase {
public void testMergeKeepsIntSortOrder() throws Exception {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setIndexSort(new Sort(new SortField("s", SortField.Type.INT)))
.setCodec(TestUtil.getDefaultCodec());
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
w.addDocument(doc("a", 1L << 32));
w.commit();
w.addDocument(doc("b", 1));
w.addDocument(doc("c", 2));
w.forceMerge(1);
try (DirectoryReader r = DirectoryReader.open(w)) {
LeafReader leaf = getOnlyLeafReader(r);
StoredFields storedFields = leaf.storedFields();
StringBuilder order = new StringBuilder();
for (int doc = 0; doc < leaf.maxDoc(); doc++) {
order.append(storedFields.document(doc).get("id"));
}
// expected: int order a (int 0), b (1), c (2), as flush and search use; actual: b, c, a
assertEquals("abc", order.toString());
}
}
}
private static Document doc(String id, long value) {
Document doc = new Document();
doc.add(new StringField("id", id, Field.Store.YES));
doc.add(new NumericDocValuesField("s", value));
return doc;
}
}
# in a copy of this finding's repro folder (repro/LFV-779/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.index.TestIntIndexSortMergeOrder
- Expected vs actual
- Expected the merged docID order a, b, c; actual b, c, a (
expected:<[abc]> but was:<[bca]>), with-eaand-da.
More files, environment, full output32 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: expected:<[abc]> but was:<[bca]>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.index.TestIntIndexSortMergeOrder.testMergeKeepsIntSortOrder(TestIntIndexSortMergeOrder.java:52)setIndexSortcovers "all (flushed and merged) segments"; the merge key must order documents like the sort's comparator, and flush, search and CheckIndex all use the low 32 bits.- The merge used
(int)from 6.2.0 to 8.1.1; LUCENE-8900, a refactoring, dropped it without discussion, while the FLOAT sorter kept its cast. - Lucene's own CheckIndex rejects the segment Lucene wrote.
The full argument250 words
- Contracts.
IndexWriterConfig#setIndexSort: "Set the Sort order to use for all (flushed and merged) segments."ComparableProvider#getAsComparableLong: "Returns a long so that the natural ordering of long values matches the ordering of doc IDs for the given comparator"; forIntSorterthat comparator orders by(int).MultiSorter.sort: the inputs "must already be sorted by the same sort!" - History. LUCENE-6766 (6.2.0) compared
(int)values at merge; LUCENE-7407 (7.0.0) and LUCENE-8592 (7.6.0) kept the cast. LUCENE-8900 "Simplify MultiSorter" (#9943, 8.2.0, 2019) movedcase INT:undercase LONG:and deleted the INT branch with its cast. Its thread says "I merged the logic for integers and longs"; nobody mentions the narrowing. There is no CHANGES entry. In the 8.2.0 jars, swapping in only the pre-changeMultiSorter.java, or the changed one plus the one-line cast, makes every test pass. - The other INT ordering paths read the low 32 bits: flush, the value comparison of
IntComparator, CheckIndex,IndexSortSortedNumericDocValuesRangeQuery; and the 32-bit FLOAT sorter narrows at merge and at flush, so a FLOAT index sort over a long with high bits set merges in order. (The skip-index competitive iterator of sorted search compares raw long block ranges; it was not run.) - Tests.
TestIndexSortinguses only int-domain values for INT sorts; with the fix it passes 62 of 62. - Precedent. Upstream treated accepted input that leaves an index-sorted segment failing CheckIndex as a bug: LUCENE-8592 (#9638, "a bad bug ... only a reindex can restore the correct sort order"), LUCENE-7581 and LUCENE-8441.
The devil's-advocate check265 words
The strongest case for "caller error": SortField.Type.INT says "Sort values are Integer" and "Fields must either be not indexed, or indexed with IntPoint"; IntField cannot produce such a value; no upstream test uses one; LUCENE-8900 meant to change nothing for int-range values, where long order equals int order. "You stored longs, sort with LONG." reviewers from another model family (OpenAI GPT-5.6), reading code without running it, said not real with medium confidence on this argument alone; both agreed with the root cause. They read main at ea57360912ed, where IndexSorter.java and MultiSorter.java are identical to a9779ef3c193. A maintainer may also read the bug as missing validation of an out-of-domain INT value; a 2011 issue, #4265 (LUCENE-3192, the old search-time doc-values API), already called SortField.INT over long-valued doc values an inconsistency.
Why it fails: IndexWriter accepts the value without a check. Accepting 64-bit storage does not by itself define what an INT sort means for every value, but flush, the search comparator and CheckIndex all order it by the low 32 bits; of the paths that decide document order, only the merge disagrees. The result is not a consistent "garbage" order but a segment Lucene's own CheckIndex rejects and its own search reads wrongly; 6.2.0 to 8.1.1 produced the consistent truncated order. The cast was the original design and was removed without discussion in a simplification. The argument bears on the fix (restore the cast, or reject such values at index time) and on severity, not on whether merge contradicts flush. Verdict: not intended, a defect (the independent reproduction, the intended-behaviour check and the adjudication).
Recommended Restore the (int) cast in the INT merge key (1 line)
Restore the cast in the INT merge key, as before 8.2.0: return (int) values.longValue(); in IndexSorter.IntSorter#getComparableProviders, one line. It fixes SortField and SortedNumericSortField INT sorts. The repro passes, and so do the index-sort, sort and CheckIndex suites and the whole core module. Segments merged before the fix still need a reindex.
--- a/lucene/core/src/java/org/apache/lucene/index/IndexSorter.java
+++ b/lucene/core/src/java/org/apache/lucene/index/IndexSorter.java
@@ -225,7 +225,7 @@ public interface IndexSorter {
providers[readerIndex] =
docID -> {
if (values.advanceExact(docID)) {
- return values.longValue();
+ return (int) values.longValue();
} else {
return missingValue;
}All options and trade-offs4 options · 589 words
Option A was validated in a scratch worktree at a9779ef3c193 (removed afterwards); B and C were not run. Until a fix ships, users can sort 64-bit fields with Type.LONG, or keep INT-sort values in the int range.
In IntSorter#getComparableProviders return (int) values.longValue(), the same low-32-bit key that flush, IntComparator and CheckIndex use, as from 6.2.0 to 8.1.1 and as FloatSorter does. A short comment ("INT sorts compare the low 32 bits, like getDocComparator") would help the next reader.
--- a/lucene/core/src/java/org/apache/lucene/index/IndexSorter.java
+++ b/lucene/core/src/java/org/apache/lucene/index/IndexSorter.java
@@ -225,7 +225,7 @@ public interface IndexSorter {
providers[readerIndex] =
docID -> {
if (values.advanceExact(docID)) {
- return values.longValue();
+ return (int) values.longValue();
} else {
return missingValue;
}Trade-offs
Keeps accepting out-of-range values silently, now with consistent low-32-bit semantics, so the application still does not hear about its type mistake. One narrowing per merge key; no benchmark was run. It prevents new damage only: segments already merged by 8.2.0 or later stay misordered and need a reindex; a CHANGES entry should say so, and that CheckIndex at level 2 or higher finds them.
What the checks validated
- Through gradle at
a9779ef3c193: this doc's test fails 5 of 7 on stock (assertions on and off) and passes 7 of 7 with the fix (on and off). - With the fix: 15 classes and 245 tests of the index-sort, sort and CheckIndex suites (
TestIndexSorting,TestSortingCodecReader,TestCheckIndex,TestIndexSortSortedNumericDocValuesRangeQuery, theTestSort*andTestTopFieldCollector*classes and this test); the whole:lucene:core:test(8,896 tests, 0 failures, with assertions on and off);:lucene:core:check -x test(google-java-format, ecj lint, forbidden APIs) passes with the test. - Earlier, with the same line as a shadow class: the finder's 5 tests, the independent reproducer's 8 tests, upstream
TestIndexSorting(62 tests) and the audit's 13 tests pass; in the 8.2.0 jars the same one-line restore makes every test pass.
Throw IllegalArgumentException at addDocument / updateDocument (and in the columnar path) when an INT index-sort field gets a value outside the int range, next to the existing doc-values type check in IndexingChain.
Trade-offs
Loud, and closer to "Sort values are Integer". But it adds a per-value check on the indexing path of sort fields, makes applications that index such values today start failing, and does nothing for segments written by older versions, which a merge would still misorder. Best combined with the cast, not instead of it. Not run.
Order INT sorts by the full 64-bit value everywhere.
Trade-offs
For in-range values nothing visible changes (int and long order coincide), but every existing INT-sorted segment that holds an out-of-range value whose (int) order differs from its full-long order was flushed by (int), and would then fail CheckIndex and break early termination. It also changes what an INT sort means for such values at search time. Not viable as the fix. Not run.
Use SortField.Type.LONG (or SortedNumericSortField with Type.LONG) for fields that hold 64-bit values, or keep INT-sort values inside the int range. An existing index cannot change its index sort ("cannot change previous indexSort="), so switching means a reindex, which also repairs merged segments.
Trade-offs
Needs an application change and a reindex; does not repair merged segments in place.
What the checks validated
The in-range control of this doc's test merges correctly on stock main.
Downstream: this matters only to applications that store 64-bit values in a field declared as an INT index sort; it changes document order and top hits, never which documents match.
The finder, an AI tool, compared the flush comparator, the merge key and the search comparator of every index sorter while building the reference model for a bounded-exhaustive test of index sorting, and saw that only the INT merge key lacks the int narrowing. A JUnit on the stock jar, CheckIndex and a release bisection confirmed it.
The method in full97 words
The hunt unit ran a bounded-exhaustive, model-based test of index sorting at flush and merge (13 sort types, document blocks, rejected documents). Writing its reference comparator meant pairing, for every IndexSorter, the flush-time DocComparator, the merge-time ComparableProvider and the search comparator. The INT row was the only one whose merge key does not narrow to (int); FLOAT and DOUBLE differ only for sign-bit NaNs, a sibling bug. The exhaustive runs did not find it themselves, because all their keys are inside the int range. A public-API JUnit on the stock main jar confirmed it.
No prior report found: 390 read-only queries on 2026-10-27 over GitHub, JIRA, the mailing lists, downstream projects and the web, plus the changed files of all 409 open PRs. Closest: LUCENE-8592 (fixed in 7.6), the same kind of merge-order bug from another cause, and LUCENE-8900, the change that dropped the cast without discussing it.
Searches5 searches
Keyword search cannot prove absence: a JIRA-era report worded differently could have been missed, some web requests were throttled, and private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr; Elasticsearch, OpenSearch, Solr) | 2026-10-27 | a scripted prior-report search in 5 runs (identifiers, the user's view, history) and 88 GraphQL phrasings ("index sort" int truncated, "low 32 bits", "Sort values are Integer", SortedNumericSortField INT merge, "sorts after docID"); the review threads of every PR that touched these files read in full | no report; #9943 (LUCENE-8900, the introducing change, no mention of the cast); #9638 (LUCENE-8592, the precedent); #11247 (LUCENE-10210, same CheckIndex message on a LONG sort, other cause); #16809 / #16810 (same symptoms from doc-values updates) |
| Open PRs | 2026-10-27 | changed files of all 409 open apache/lucene PRs | none touches IndexSorter.java; #16571, #16847 and #16851 touch MultiSorter.java without changing merge keys |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-27 | 60 tool queries and 26 direct archive queries | only JIRA mirrors of the items above and an unrelated 2014 thread |
| JIRA archive and web | 2026-10-27 | 22 JIRA queries (mostly issues created after 2019), 28 web queries | no INT-truncation report; #4265 (LUCENE-3192, a 2011 naming note on the old search API) is not about index sorting |
| Earlier runs | 2026-10-27 | the finder's prior-art run (51 queries) and the devil's advocate's (43) | nothing |
Introduced in 8.2.0 by LUCENE-8900 "Simplify MultiSorter" (2019-07-09), which folded the INT merge case into the LONG case and dropped its cast. Every release run from 8.2.0 to 10.5.2 and main fails; 6.2.0 to 8.1.1 pass; upstream branches still carry the line.
Versions tested8 rows
The API exists from 6.2.0 (IndexWriterConfig#setIndexSort). LUCENE-9330 (8.6.0) moved the code unchanged into IndexSorter.IntSorter; LUCENE-9570 only reformatted it. Up to 8.10.x CheckIndex records the index-sort error but reports clean=true; from 8.11.4 and 9.x it reports clean=false. The evidence has three tiers: executed on 18 releases and main (the list below); read only, not executed, for every other release tag in the local history clone from 8.2.0 to 10.5.1 (the INT merge key has no cast); and 8.8.2, 8.10.0 and 8.11.0-8.11.3 affected by inference only (tags not in the clone, between failing releases of the same branch).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test fails 5 of 7 through gradle and the runner, assertions on and off; the finder's and the reproducer's tests fail too |
upstream main 20ced71c1e6e, branch_10x, branch_10_6 | read | same line on 2026-10-27 (main; branch_10x and branch_10_6 and main f7bcc0abd816 in an earlier read that day); no open PR touches IndexSorter.java |
| 10.0.0, 10.5.2 | affected | the finder's test fails 3 of 5; this doc's test fails 5 of 7 on 10.5.2 |
| 9.0.0, 9.5.0, 9.12.3 | affected | the finder's 9.x / 8.x variants fail; the reproducer's test fails 5 of 8 on 9.12.3 |
| 8.2.0, 8.5.2, 8.6.0, 8.9.0, 8.10.1, 8.11.4 | first affected 8.2.0 | merged order bca; wrong top hit executed on all of them except 8.9.0, which ran only the legacy test (no search test); CheckIndex records the error (clean=true up to 8.10.x) |
| 8.2.0 + one class swapped | not affected | the pre-LUCENE-8900 MultiSorter.java, or the changed one plus the one-line cast: every test passes |
| 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.1.0, 8.1.1 | last unaffected 8.1.1 | the legacy test passes 5 of 5, merged order abc for both segment orders |
| main + the one-line fix | not affected | this doc's test 7 of 7; the index-sort, sort and CheckIndex suites and the whole core module pass |
An independent reproducer, a devil's advocate and an adjudication say real; the adjudication re-ran with a fresh seed. Reviews by another model family, which could not run code, said not real on input domain only and agreed with the root cause. A credibility audit kept the row; this doc's test fails on stock and passes with the fix.
Checks and verdicts422 words
Every executing check used stock jars; on main each test was run with assertions on and off; the audit and most release-jar runs used a single mode. The cross-model reviews were reading-only; separate runs re-ran the finder's test, which failed 3 of 5 each time.
| Check | Verdict | What it found |
|---|---|---|
| Finder (index sort at flush and merge) | candidate | 5-test JUnit fails 3 of 5 on main (assertions on and off), 10.5.2 and 9.12.3, and 2 of 5 on 8.2.0, 8.5.2 and 8.6.0; passes on 8.1.1 and with the fix; upstream TestIndexSorting 62 of 62 with the fix. |
| Refute review (OpenAI GPT-5.6, reading only) | not real, medium | Input domain only ("Sort values are Integer"; IntField cannot produce the value); agreed with the root cause and public reachability; said a maintainer may still take the cast. A separate re-run: 3 of 5 fail. It read main at ea57360912ed (same IndexSorter.java and MultiSorter.java). |
| Independent reproducer | real | Own 8-test JUnit (a = 3,000,000,000, b = -5, c = 7): 5 of 8 fail on main, 10.5.2 and 9.12.3; reverse and sorted-numeric variants fail; wrong top hit also with threshold Integer.MAX_VALUE; 3 controls pass; 8 of 8 with its own one-line fix. |
| Works-as-designed review (OpenAI GPT-5.6, reading only) and re-runs | not real, medium | Same input-domain argument, rated moderate (read main at ea57360912ed, same two files); root cause agrees. A separate re-run of the finder's test: 3 of 5 fail with assertions on and off, 5 of 5 pass with the fix. |
| Devil's advocate | real | Built the strongest caller-error case and answered it from history, the other INT paths and upstream precedent; reproduced on main and 10.5.2; probe: the columnar addBatch path misorders too, a FLOAT control stays sorted. |
| Adjudication | confirmed, medium | Re-ran the independent reproduction's test with fresh seed C0FFEE02: 5 of 8 fail with assertions on and off; 8 of 8 pass with the fix. Set aside the two reading-only "not real" verdicts. |
| Credibility audit | keep | Finder's test fails 3 of 5 (-da), the independent reproduction's 5 of 8 (-ea); both pass 13 of 13 with the cast. |
| Security assessment | none, high confidence | Ordering and CheckIndex status only; out-of-domain value under a trusted configuration; file publicly. |
| This doc's test and fix validation | fails on stock, passes with the fix | Gradle and the runner: 5 of 7 fail on main and 10.5.2 (assertions on and off); with the fix 7 of 7, and the suites listed under Suggested fix pass. |
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 544 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,677 words
- Registry title
- With an INT index sort, merges order documents by the full 64-bit doc value while flush, sorted search and CheckIndex use its low 32 bits, so merging a value outside the int range writes a segment that violates its declared index sort (CheckIndex at level 2 or higher rejects it) and sorted searches that terminate early on the index sort return wrong top hits (a regression in 8.2.0)
- Area
- index / index sorting at merge time (IndexSorter.IntSorter merge keys, MultiSorter) -> search / TopFieldCollector index-sort early termination
- Priority
- Low, rank 278: Needs an application type mistake: a long outside the int range under an opt-in INT index sort.
- Impact class
- data corruption
- Impact note
- Hits only applications that opt into an index sort with SortField.Type.INT (or SortedNumericSortField with Type.INT) over a raw NumericDocValuesField or SortedNumericDocValuesField and store in it a long outside [Integer.MIN_VALUE, Integer.MAX_VALUE] (IntField cannot produce such a value; IndexWriter accepts it without any range check): after any merge that involves such a document, the persisted segment is in 64-bit order instead of the declared INT order, CheckIndex at level 2 or higher rejects it (the command-line default level 1 does not run the sort check), searches sorted by the index sort (or a prefix of it) return wrong top hits with any totalHitsThreshold, and range queries over the index-sort field return wrong hit sets and counts [exec 2026-10-29]; deterministic with -ea and -da, 3 documents in 2 segments, ascending and reverse sorts, in every release from 8.2.0 to 10.5.2, in 10.6.0 (by reading) and on main; in-range values are unaffected, and segments already merged stay misordered after a code fix [read].
- Summary
- You configure an index sort on SortField("s", SortField.Type.INT), index documents with NumericDocValuesField("s", value), one of them with a value that does not fit in an int (for example 2^32, whose int value is 0), and let segments merge, expecting the merged segment to be sorted the way a flushed segment of the same documents is. Flush (IntSorter#getDocComparator), the search-time IntComparator and CheckIndex all compare (int) of the value, but the merge key (IntSorter#getComparableProviders) is the full long, so forceMerge(1) of the segments [a = 2^32] and [b = 1, c = 2] writes the order b, c, a instead of a, b, c. CheckIndex (level 2 or higher) then reports 'segment has indexSort=<int: "s"> but docID=1 sorts after docID=2' and a top-1 search sorted by the index sort returns b instead of a, while before the merge the same index is clean and returns a.
- Introduced
- 17f2f96a5cf8e0001a81b9327c2ddced053e6934 on master and its branch_8x backport aad4b411cdd2d517328a2444147b407d4a38343d, 'LUCENE-8900: Simplify MultiSorter.' (JIRA LUCENE-8900, migrated as apache/lucene#9943; a patch on the JIRA issue, no pull request; by jpountz, +1 from danmuzi). It moved 'case INT:' under 'case LONG:' in MultiSorter#getComparableProviders and deleted the INT branch that returned (int) values.longValue(). LUCENE-9330 (8.6.0, apache/lucene-solr#1440) then moved the code unchanged into IndexSorter.IntSorter#getComparableProviders, and LUCENE-9570 (2d6ad2fee6d, formatting only) is what git blame shows for IndexSorter.java:228 on main. The merge-time (int) narrowing dates from the first index-sort merge code (LUCENE-6766, 6.2.0) and was kept by LUCENE-8592 (7.6.0). [read: history clone, git log -S and the LUCENE-8900 diff] [exec: 8.1.1 passes and 8.2.0 fails; in the 8.2.0 jars, shadowing only MultiSorter.java with its pre-commit version, or with the LUCENE-8900 version plus the one-line (int) cast, turns every failure into a pass] (2019-07-09)
- First affected version
- 8.2.0 [exec] (the first release with LUCENE-8900). 8.1.1, the last release before it, passes [exec], as do 6.2.0 (the first release with IndexWriterConfig#setIndexSort), 6.6.6, 7.0.0, 7.7.3, 8.0.0 and 8.1.0 [exec]
- Affected versions
- every release from 8.2.0 through 10.6.0 (8.2.x to 8.11.x, all of 9.x and 10.x; 10.6.0 by reading) and main a9779ef3c193 and 645d4fe14a2 [exec 2026-10-29]; branch_10x and branch_10_6 (10.6.0, released 2026-10-29) carry the same line [read]. Executed (JDK 25, -ea unless stated): the follow-up's runs fail on 8.2.0, 8.6.0, 8.9.0, 8.10.1, 8.11.4, 9.0.0, 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and pass on 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.1.0 and 8.1.1; the finder's JUnit also fails on 8.5.2; the independent reproduction's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check reproduced on main (-ea, -da) and 10.5.2 (-da). On 8.2.0 and 8.10.1 CheckIndex records the index-sort error but still reports clean=true (probe), while 8.11.4 and 9.x report clean=false; the wrong order and the wrong top hit are there in all of them. Inferred only (not tagged in the local clone, between failing releases on the same branch): 8.8.2, 8.10.0, 8.11.0 to 8.11.3. Not fixed upstream: upstream main 645d4fe14a2 (2026-10-29), branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b still return values.longValue() at IndexSorter.java:228, and no open PR touches IndexSorter.java [read].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record, verdict new): 390 query records, from the finder's scripted prior-report search (51 queries), the intended-behaviour check's (43) and the follow-up's (48 GitHub issue/PR, 9 commit, 6 downstream-repo, 60 mailing-list queries over dev@, java-user@, issues@ and commits@, 12 JIRA-archive and 18 web), plus 88 GitHub GraphQL phrasings over apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch and Solr, 26 direct list-archive queries, 10 JIRA queries and 10 web searches; the issue and review threads of the items that touched these files read in full; the changed-file lists of all 409 open apache/lucene PRs (none touches IndexSorter.java; #16571, #16847 and #16851 touch MultiSorter.java without changing comparable values). Related, not reports: #9943 (LUCENE-8900, the introducing change; its thread never mentions the cast or out-of-range values), #10370 (LUCENE-9330, moved the code unchanged), #9638 (LUCENE-8592, fixed in 7.6: the same bug class, a merge order that differs from the index sort, from a different cause; its reporter called it 'a bad bug' that only a reindex repairs), #11247 (LUCENE-10210: the same CheckIndex message on a LONG sort, another cause), #16809 and its fix PR #16810 (open: the same symptoms from doc-values updates; would not fix this), #9487 (LUCENE-8441: doc-values type check for index-sort fields, no range check), #8632 (LUCENE-7581), #4265 (LUCENE-3192, open since 2011: SortField.INT over long doc values as a naming inconsistency in the old search-time API, not index sorting or merging), #13320 (origin of the sibling LFV-145). Gaps: searches cannot prove absence; a JIRA-era report worded differently could have been missed; some web and Q&A requests were throttled (one 2021 how-to Q&A judged from its snippet); private reports are not visible.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26, tested 2026-10-27): this doc's test through gradle in a scratch worktree (stock fails 5 of 7 with assertions on and off; with the fix it passes 7 of 7, and so do 15 classes and 245 tests of the index-sort, sort and CheckIndex suites and the whole:lucene:core:test(8,896 tests, 0 failures, with assertions on and off);:lucene:core:check -x test(google-java-format, ecj lint, forbidden APIs) passes with the test; the worktree was removed afterwards) and through the standalone JUnit runner on main and on the 10.5.2 release jar (-eaand-daeach); the follow-up's history runs (its legacy test and the finder's 8.x/9.x/10.x variants) on 17 releases from 6.2.0 to 10.5.2, plus the finder's test on 8.5.2 (18 releases in all; fail from 8.2.0, pass from 6.2.0 to 8.1.1); the independent reproducer's test on main, 10.5.2 and 9.12.3; upstream main20ced71c1e6eread on 2026-10-27 (line unchanged; an earlier read that day of mainf7bcc0abd816, branch_10x and branch_10_6 showed the same line).
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:57-63 (ComparableProvider contract: 'Returns a long so that the natural ordering of long values matches the ordering of doc IDs for the given comparator'), :212-233 (IntSorter#getComparableProviders, :228 'return values.longValue();'), :238-256 (IntSorter#getDocComparator, :249 '(int) dvs.longValue()'), :355-366 (FloatSorter merge key narrows with (int))lucene/core/src/java/org/apache/lucene/search/comparators/IntComparator.java:79-85 (getValueForDoc: '(int) docValues.longValue()'); search/SortField.java:76-80 (Type.INT: 'Sort values are Integer'); search/SortedNumericSortField.java:376 (getIndexSorter case INT uses IntSorter)lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504 (setIndexSort: 'all (flushed and merged) segments'); index/IndexingChain.java:1635ff (validateIndexSortDVType checks only the doc-values type); index/CheckIndex.java:1114 (index-sort test from level 2), :1280 ('sorts after docID'), :1189-1191 ('Index Sort test failed'), :4741-4757 (levels; DEFAULT_VALUE 1); index/MultiSorter.java:41-153 (k-way merge on the comparable keys); search/TopFieldCollector.java:151-172 (index-sort early termination)lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:178-224 (count; #16893 hasBlocks guard :182, numericValues != null :204), :517-545, :559-616, :626-640; document/SortedNumericDocValuesRangeQuery.java:139-147, :237-253 (canUsePrimarySortShortcut); document/SortedSkipperScorerSupplier.java:113-130 (main 645d4fe14a2)
- The finding in 30 seconds
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:57-63 (ComparableProvider contract), 98-111 (fromComparableProviders, Long.compare), 147-150 (default getComparableValues), 212-235 (IntSorter#getComparableProviders, 228 no cast), 238-253 (getDocComparator, 249 cast, 252 Integer.compare), 363 and 382 (FloatSorter narrows at merge and flush)a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/comparators/IntComparator.java:81; index/CheckIndex.java:1219-1281 (testSort, 1241 search comparators, 1275-1281 message)a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:35-39, 84, 89-102, 118-136, 137-139; index/MergeState.java:223a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1384, 1635 (validateIndexSortDVType: doc-values type only); index/IndexWriterConfig.java:504a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:59, 92-111, 152-171 (early termination on an index-sort prefix)
- Who is affected
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:62-69, document/SortedNumericDocValuesField.java:70-77 (constructors: 64-bit long value); search/SortField.java:574-579; search/SortedNumericSortField.java:374-378a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:499, 1114, 1189-1191, 4673, 4748, 4754a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/document/column/LongColumn.java:35 (INT: low 32 bits, sign-extended); search/IndexSortSortedNumericDocValuesRangeQuery.java:621a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:118-130, 279, 284, 530 (skip-index competitive iterator; read, not run)
- Why it looks unintended
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504; index/IndexSorter.java:57-63, 228, 249, 363, 382; index/MultiSorter.java:35-39; search/SortField.java:76-80
- Suggested fix
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1635; index/IndexWriter.java:1220 (cannot change previous indexSort)
- TestIntIndexSortMergeOrder.java (JUnit test):
repro/LFV-779/src/org/apache/lucene/index/TestIntIndexSortMergeOrder.java(63 lines)
A step-by-step picture of the flush keys, the merge keys and the k-way merge on the three-document example of this doc's repro; the PNG is the summary still.
assets/LFV-779/bug.png(544 KB)
