All findings · Lucene finding · LFV-779 · indexsorter-int-merge-comparable-not-truncated

INT index sorts merge by the full 64-bit value, so out-of-int-range values leave merged segments out of order

An INT index sort orders by the low 32 bits of a doc value at flush and search, but merges compare all 64 bits. Segments {2^32} and {1, 2} merge to [1, 2, 2^32], not [2^32, 1, 2], so CheckIndex rejects the result and a sorted top-1 search returns 1.

Summary

Priority
Low, rank 278Needs an application type mistake: a long outside the int range under an opt-in INT index sort.
Impact
data corruption
Since
8.2.0 (LUCENE-8900, 2019)introduced 2019-07-09 · about 7 years
Affected
8.2.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
code reading while building a model-based test of index sorting at flush and merge

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

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.

Diagram of an INT index sort: segment 1 holds document a with doc value 2^32, segment 2 holds b = 1 and c = 2. Flush and search read a as (int) 2^32 = 0, so the expected order is a, b, c. The merge compares the full 64-bit values 4,294,967,296, 1 and 2, so the merged segment is b, c, a; CheckIndex reports that docID=1 sorts after docID=2, and a top-1 search sorted by the index sort returns b instead of a.
One example, step by step: segments {a = 2^32} and {b = 1, c = 2} under an INT index sort. Flush and search count a as 0; the merge counts it as 4,294,967,296, so the merged segment becomes b, c, a and a top-1 index-sorted search returns b. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Sort the index by an int field, setIndexSort(new Sort(new SortField("s", SortField.Type.INT))), and fill it with NumericDocValuesField, which accepts any long. Flush document a = 2^32 (4,294,967,296) into one segment, and b = 1 and c = 2 into another.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Who is affected

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.

Terms

  • Index sort: IndexWriterConfig#setIndexSort keeps "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 one long per document (through getComparableValues, MultiSorter.java:84), and "the natural ordering of long values" must match the sort's comparator (IndexSorter.java:57-63).

The bug

  1. NumericDocValuesField and SortedNumericDocValuesField take 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.
  2. Flush sorts with values[docID] = (int) dvs.longValue() and Integer.compare (IndexSorter.java:249, :252). The search comparator reads (int) docValues.longValue() (IntComparator.java:81), and CheckIndex.testSort uses the search comparators (CheckIndex.java:1241).
  3. The merge key of IntSorter#getComparableProviders is return values.longValue(); with no cast (IndexSorter.java:228). The sibling FloatSorter, also a 32-bit sorter over NUMERIC doc values, narrows with (int) at merge and at flush (:363, :382).
  4. MultiSorter takes 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.
  5. 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 any totalHitsThreshold, including Integer.MAX_VALUE.

The numbers

checkexpectedmain
merged order, ascendinga, b, cb, c, a
merged order, reverse INT sortc, b, aa, c, b
merged order, SortedNumericSortField(Type.INT)a, b, cb, c, a
CheckIndex level 2cleansegment has indexSort=<int: "s"> but docID=1 sorts after docID=2
top 1 sorted by the index sortab
control: one flushed segmenta, b, ca, b, c
control: a = 0 (in range), mergeda, b, ca, 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. IntField always stores ints. The experimental columnar IndexWriter#addBatch with a LongColumn of NumericKind.INT reaches the same misorder.
  • Opt-in. Index sorting is off by default, and in-range values are unaffected.
  • Any merge. Background merges and forceMerge both go through MultiSorter.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 CheckIndex built in code without setLevel do not run the check. Up to 8.10.x CheckIndex records the error but still reports clean=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) topValue and 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), NumericComparator can 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.

Minimal repro

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

TestIntIndexSortMergeOrder.java (JUnit test)63 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 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;
  }
}
Command
# 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 -ea and -da.

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

More files, environment, full output32 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

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

Why it looks unintended

  • setIndexSort covers "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"; for IntSorter that 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) moved case INT: under case 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-change MultiSorter.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. TestIndexSorting uses 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).

Suggested fix

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.

Restore the (int) cast in the INT merge key (1 line) (validated by the checks) (recommended)

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, the TestSort* and TestTopFieldCollector* 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.

Reject out-of-int-range values for INT index-sort fields at index time

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.

Make flush and search use the full long instead

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.

Workaround until a fix ships (validated by the checks)

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 note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr; Elasticsearch, OpenSearch, Solr)2026-10-27a 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 fullno 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 PRs2026-10-27changed files of all 409 open apache/lucene PRsnone touches IndexSorter.java; #16571, #16847 and #16851 touch MultiSorter.java without changing merge keys
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2760 tool queries and 26 direct archive queriesonly JIRA mirrors of the items above and an unrelated 2014 thread
JIRA archive and web2026-10-2722 JIRA queries (mostly issues created after 2019), 28 web queriesno INT-truncation report; #4265 (LUCENE-3192, a 2011 naming note on the old search API) is not about index sorting
Earlier runs2026-10-27the finder's prior-art run (51 queries) and the devil's advocate's (43)nothing

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis 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 readsame 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 affectedthe 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 affectedthe 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.0merged 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 affectedthe 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.1the legacy test passes 5 of 5, merged order abc for both segment orders
main + the one-line fix not affectedthis doc's test 7 of 7; the index-sort, sort and CheckIndex suites and the whole core module pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (index sort at flush and merge) candidate5-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, mediumInput 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 realOwn 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, mediumSame 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 realBuilt 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, mediumRe-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 keepFinder'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 confidenceOrdering 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 fixGradle 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.

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

Finding record

LFV-779, snapshot of 2026-11-06

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 (-ea and -da each); 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 main 20ced71c1e6e read on 2026-10-27 (line unchanged; an earlier read that day of main f7bcc0abd816, branch_10x and branch_10_6 showed the same line).

Lucene and upstream sources

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:223
  • a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1384, 1635 (validateIndexSortDVType: doc-values type only); index/IndexWriterConfig.java:504
  • a 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-378
  • a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:499, 1114, 1189-1191, 4673, 4748, 4754
  • a 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:621
  • a 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)

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

Repro files

About the diagrams

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.

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.