All findings · Lucene finding · LFV-860 · primary-sort-field-skips-hidden-or-derived-index-sort-field

Sort.getPrimarySortField skips an index-sort field that still orders the documents, so range queries on the next sort field return wrong hits

In 10.6, range queries on the second index-sort field return wrong hits when the reader hides the first sort field or that field is a BinarySortField keyed on another field: f:[1 TO 3] finds 0 of 2 documents, and LongField.newRangeQuery throws. 10.5.2 is correct.

Summary

Priority
Medium, rank 94A core range-query regression released in 10.6.0 that silently returns wrong hits, but only with a multi-field index sort plus a field-hiding reader or a derived-key BinarySortField.
Impact
silent wrong results
Since
10.6.0 (released 2026-10-29), #16424, 2026-07-30introduced 2026-07-30
Affected
main and 10.6.0; 10.5.2 and older are clean
Status
documented
Found by
caller inventory, then a metamorphic test and a bounded-exhaustive check

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

With an index sort, a range query on the sort field can skip the per-document check: its matches are one contiguous run of doc IDs, found by binary search. Since 10.6 this also works for the second sort field when the first one has no effect in a segment, and that test can be fooled.

An index sorted by (c, f) holds five documents in doc-ID order with c = 0, 0, 1, 1, 2 and f = 5, 9, 1, 7, 3. The plain reader reports c as the primary sort and f:[1 TO 3] returns docs 2 and 4. Through a reader that hides c, Sort.getPrimarySortField reports f; the range query assumes f ascends, starts the range at doc 0 and ends it at doc 0, the first document with f > 3, and returns 0 hits. LongField.newRangeQuery throws IllegalArgumentException instead. 10.5.2 returns docs 2 and 4.
The repro's five documents in doc-ID order, sorted by (c, f). With c hidden, getPrimarySortField reports f, the range query treats f as ascending, and the run of matching doc IDs for f:[1 TO 3] comes out empty instead of docs 2 and 4. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Sort an index by two fields, (c, f), and add five documents: (c, f) = (1,7), (0,9), (2,3), (0,5), (1,1). After forceMerge(1) the segment is ordered by c first, so f reads 5, 9, 1, 7, 3 in doc-ID order.
  2. The reader does not list c in its FieldInfos, in either of two ways that IndexWriter accepts and the API documentation does not forbid: c is a BinarySortField subclass whose key is computed from another doc-values field, so no field named c exists; or a FilterLeafReader hides c but, as every FilterLeafReader does by default, still reports the index sort (c, f).
  3. Run NumericDocValuesField.newSlowRangeQuery("f", 1, 3). You expect 2 hits, the documents with f = 1 and f = 3, as the plain reader and 10.5.2 return.
  4. You get 0 hits and no error; LongField.newRangeQuery("f", 1, 3) throws IllegalArgumentException: minDoc must be < maxDoc. Over 55 ranges on an 8-document index, 43 are wrong (46 for LongField), some with documents outside the range.
  5. Why: new in 10.6, Sort.getPrimarySortField skips a sort field that "would have no effect", judged from the reader. It finds no FieldInfo for c and reports f. The query then treats f as ascending and stops at the first document with f > 3, doc 0 (f = 5): an empty range.

Who is affected

Applications on main or 10.6.0 with an index sort of two or more fields (opt-in) that run doc-values range queries on the second sort field, and either sort by a BinarySortField subclass with a derived key or read through a FilterLeafReader that hides the first sort field but keeps the sort. Rare; then many such ranges are silently wrong.

Mechanism, terms and numbers786 words

Sort.getPrimarySortField(LeafReader), added in 10.6 by #16424, tells three doc-values range queries which field the segment is effectively sorted by: it skips leading index-sort fields that "would have no effect" in the segment, so a query on the second sort field can binary-search instead of testing every document. It decides "no effect" from the reader it is given (the field is missing from getFieldInfos(), or its skipper holds one value), but the documents' order belongs to the segment. When the first sort field is a BinarySortField whose key comes from another doc-values field, or a FilterLeafReader hides it while keeping the index sort, the method skips a field that orders every document, and the range queries on the next sort field cut a doc-ID range out of documents that are not in that order. In a 5-document index f:[1 TO 3] returns 0 hits instead of 2 and LongField.newRangeQuery throws IllegalArgumentException; over 55 ranges of an 8-document index 43 are wrong (46 for LongField). It affects main and 10.6.0, with assertions on and off; 10.5.2 and older are correct. Reading the evidence from the unwrapped segment and skipping only the built-in sort-field classes fixes every repro, and the neighbouring core suites pass.

Terms

  • Index sort: IndexWriterConfig.setIndexSort(new Sort(c, f)) stores each segment's documents ordered by c, then by f among equal c. The reader reports it in getMetaData().sort(), "the order in which documents from this index are sorted" (LeafMetaData.java:32).
  • Index-sort range shortcut: NumericDocValuesField, SortedNumericDocValuesField and SortedDocValuesField.newSlowRangeQuery on a skip-indexed field, and LongField / IntField.newRangeQuery, check whether the segment's primary sort is the queried field. If it is, and the query's other guards hold (for the doc-values queries a single-valued field whose skip index covers every document; for LongField / IntField an INT or LONG sort and a one-dimensional points index, among others), the matches form one doc-ID range and the query finds its two ends instead of testing every document.
  • Sort.getPrimarySortField(reader) (new in 10.6, #16424): the primary sort those queries ask for. It skips leading sort fields that "would have no effect" in the segment (Sort.java:141-143), so a query on the second sort field can use the shortcut when the first one is empty or constant there.

The bug

  1. It decides "no effect" from the reader: the field is missing from reader.getFieldInfos() ("Field has no values in this segment", Sort.java:156-159), or its skipper is dense with minValue() == maxValue() (:164-169).
  2. The order is a property of the segment, and a reader can omit the first sort field while the segment is still ordered by it:
    • a BinarySortField subclass derives its key from another doc-values field, as its javadoc allows (BinarySortField.java:41-44); no field of its own name exists;
    • a FilterLeafReader hides the field and, by default, still delegates getMetaData() (FilterLeafReader.java:477-481).
  3. getPrimarySortField returns f, and the range query on f (SortedNumericDocValuesRangeQuery.java:237-247, SortedSetDocValuesRangeQuery.java:288-298, IndexSortSortedNumericDocValuesRangeQuery.java:198, 425, 523) cuts a doc-ID range out of a segment that is not ordered by f.

The example

Index sorted by (c, f), five documents, after forceMerge(1):

doc ID01234
c00112
f59173

f:[1 TO 3] matches docs 2 and 4. Assuming f ascends, the shortcut starts the range at doc 0 (no f is below 1) and ends it before the first document from there with f > 3, which is doc 0 itself (f = 5): the empty range [0, 0), 0 hits (SortedSkipperScorerSupplier.java:113-182). LongField.newRangeQuery finds the two ends through the points index instead, gets start 2 and end 0, and DocIdSetIterator.range(2, 0) throws IllegalArgumentException: minDoc must be < maxDoc.

The numbers

reader and sortf:[1 TO 3], 5 docs55 ranges, 8 docs
plain reader, every form2 hits0 wrong
BinarySortField keyed on field a (no wrapper)0 hits43 wrong
c hidden, NumericDocValuesField with a skip index0 hits43 wrong
c hidden, SortedDocValuesField with a skip index0 hits43 wrong
c hidden, LongField.newRangeQueryIllegalArgumentException46 wrong: 16 throw, 30 return wrong hits (23 with documents outside the range)
BinarySortField on a constant skip-indexed field, key (that field, b): the skipper rule0 hits43 wrong
10.5.2, every form2 hits0 wrong

A bounded-exhaustive check of the stock jar (every layout of up to 5 documents over (c, f) in {0,1} x {1,2,3}, every range on f, a LONG NumericDocValuesField with a skip index, c hidden by the test framework's FieldFilterLeafReader) finds 0 of 55,980 checks wrong on the plain reader and 22,120 wrong, in 5,964 of 9,330 layouts, through a reader that hides c; 10.5.2 has 0.

Exposure and likelihood353 words
  • Who. An index sort of at least two fields (opt-in), doc-values range or exact queries on the second sort field (newSlowRangeQuery of NumericDocValuesField, SortedNumericDocValuesField and single-valued SortedDocValuesField / SortedSetDocValuesField with a skip index, or LongField / IntField.newRangeQuery without one), and one of:
    • a BinarySortField subclass whose key is derived from other doc-values fields (@lucene.experimental, added in 10.5.0 by #16202, whose javadoc invites such keys);
    • a reader wrapper that hides the first sort field and keeps the index sort: FilterLeafReader's default, the test framework's FieldFilterLeafReader ("exposes only a subset of fields"), and field-level-security views built like it. One widely used downstream field-level-security reader says it is based on FieldFilterLeafReader, filters FieldInfos and keeps getMetaData(); whether its queries reach these shortcuts was not checked;
    • weakest: a BinarySortField subclass on a skip-indexed NUMERIC field that is constant in the segment, keyed on (that field, another field).
  • Effect. Wrong hits and counts, with no error, for many ranges on the second sort field (43 of 55 in an 8-document test; 22,120 of 55,980 checks, about 40%, in the bounded check): missing matches and sometimes documents outside the range (in a 66-range probe, 16 ranges on the skip-index path and 20 on the LongField path returned extra hits; f:[2 TO 9] also returned the document with f = 1). LongField.newRangeQuery (and, by reading, IntField's) can throw IllegalArgumentException instead. Five documents are enough.
  • What still holds. In the security probe's fixture no deleted document was returned and no other FILTER clause of the same query was violated; nothing is written, and a fixed jar gives the right answers on the same index.
  • Released in 10.6.0. Main and 10.6.0 (released 2026-10-29); 10.5.2 and the older releases tested are correct, and no release before 10.6 has the method.
  • May widen. Open PR #16857 reuses the same skip rules for TopFieldCollector early termination, and open PR #16699 extends the shortcut to FLOAT and DOUBLE sorts.
  • How likely. Rare: the opt-in multi-field index sort plus an uncommon reader or sort-field setup. Once that setup exists, many range queries on the second sort field are wrong.

Minimal repro

The test shows that with an index sort (BinarySortField subclass whose key comes from doc-values field a, then f), Sort.getPrimarySortField skips the key field (no FieldInfo for its name) and returns f, so a doc-values range query on f uses its index-sort shortcut on a segment that is not sorted by f.

TestDerivedSortKeyRangeQuery.java (JUnit test)84 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.search;

import java.io.IOException;
import org.apache.lucene.document.BinaryDocValuesField;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.BinaryDocValues;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.DocValues;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.SortFieldProvider;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.DataInput;
import org.apache.lucene.store.DataOutput;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BytesRef;

public class TestDerivedSortKeyRangeQuery extends LuceneTestCase {

  public static final class KeyFromA extends BinarySortField {
    public KeyFromA() {
      super("key", false, null, "KeyFromA");
    }

    @Override
    protected BinaryDocValues getSortKeyDocValues(LeafReader reader) throws IOException {
      return DocValues.getBinary(reader, "a");
    }
  }

  public static final class KeyFromAProvider extends SortFieldProvider {
    public KeyFromAProvider() {
      super("KeyFromA");
    }

    @Override
    public SortField readSortField(DataInput in) {
      return new KeyFromA();
    }

    @Override
    public void writeSortField(SortField sf, DataOutput out) {}
  }

  public void testRangeOnSecondSortField() throws IOException {
    Sort sort = new Sort(new KeyFromA(), new SortField("f", SortField.Type.LONG));
    IndexWriterConfig iwc =
        new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()).setIndexSort(sort);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int[] af : new int[][] {{1, 1}, {0, 2}}) {
        Document doc = new Document();
        doc.add(new BinaryDocValuesField("a", new BytesRef(new byte[] {(byte) af[0]})));
        doc.add(NumericDocValuesField.indexedField("f", af[1]));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        Query query = NumericDocValuesField.newSlowRangeQuery("f", 1, 1);
        // expected 1 hit (f = 1); actual 0: the segment is in (a, f) order (f = 2, 1), not f order
        assertEquals(1, new IndexSearcher(reader).count(query));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-860/, 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:resources:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.TestDerivedSortKeyRangeQuery
Expected vs actual
Expected: count(f:[1 TO 1]) = 1. Actual: expected:<1> but was:<0>.

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

More files, environment, full output34 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
    => java.lang.AssertionError: expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.search.TestDerivedSortKeyRangeQuery.testRangeOnSecondSortField(TestDerivedSortKeyRangeQuery.java:80)

Why it looks unintended

  • getPrimarySortField's javadoc allows a skip only "If sorting against a field would have no effect"; here that field orders every document.
  • The queries promise every document in the range; 10.5.2 returns them through the same wrapper and sort.
  • BinarySortField invites keys from "other doc-values fields"; FilterLeafReader keeps the sort by default; no javadoc says to drop it.
The full argument341 words
  • The method's own contract. "If sorting against a field would have no effect then that SortField is skipped over ... This can happen for fields with no values in the segment, or fields that have only a single distinct value" (Sort.java:141-143); the code comment says "Field has no values in this segment" (:157). The segment has values of c and is ordered by them; the BinarySortField key orders every document.
  • The shortcut's contract. IndexSortSortedNumericDocValuesRangeQuery uses it only if "its primary sort is on the same field as the query", and "This fallback must be an equivalent range query -- it should produce the same documents" (IndexSortSortedNumericDocValuesRangeQuery.java:46, :55). NumericDocValuesField.newSlowRangeQuery "matches all documents whose value is between lowerValue and upperValue included" (NumericDocValuesField.java:91-92).
  • The trigger is documented. BinarySortField: "A subclass may override getSortKeyDocValues(LeafReader) to derive an order-preserving key from the stored bytes and/or other doc-values fields of the segment" (BinarySortField.java:41-44, again at :150-152), and IndexingChain accepts a sorter that reads other fields. FilterLeafReader passes every call through, "possibly transforming the data along the way" (FilterLeafReader.java:30-31), delegates getMetaData() (:477-481), and its notes cover only live docs, numDocs and cache helpers (:37-42).
  • History. #16424 (merged 2026-07-30) describes using "index-level stats from FieldInfo or associated DocValuesSkipper entries"; its one review thread is about density, and nothing in it discusses wrappers or derived keys. CHANGES: "Use secondary or lower sorts to accelerate XXDocValuesRangeQueries if the primary sort field is empty or only contains a single value" (lucene/CHANGES.txt:645-646). Before it, the shortcuts used only the first sort field, and the releases tested (10.5.0-10.5.2, 10.4.0, 10.0.0, 9.12.3, 9.10.0) give the right hits through the same wrapper and sort; by reading, so does every release before 10.6.
  • Tests. TestGetPrimarySortField covers only plain segment leaves (testPrimaryWithNoValuesInSegmentIsSkipped, :137, uses a field that is truly absent); TestBinarySortField's two subclasses read only their own field. No upstream test runs these queries through a field-hiding reader or with a derived key.
The devil's-advocate check167 words

The strongest works-as-designed case (the devil's advocate, the cross-model intended lens, the adjudication): a reader that hides an index-sort field but keeps getMetaData().sort() contradicts itself, so the wrapper should drop or rewrite the sort. There is real support for this: on main, a 10.6 snapshot and 10.5.2, a search sorted by (c, f) through such a reader returns view-inconsistent top hits (f = [1, 5] instead of [1, 3]), because TopFieldCollector early termination trusts the metadata sort. And BinarySortField is experimental; its in-tree subclasses read only their own field.

Why it fails: no javadoc states that obligation, FilterLeafReader delegates the sort by default and Lucene's own field-subset reader keeps it; the query here never references the hidden field and was right through the same wrapper on 10.5.2, while the older inconsistency affects only sorts on the hidden field; a reader that hides c completely (FieldInfos, terms, points, every doc-values type, the skipper and norms) fails the same way; and the BinarySortField form needs no wrapper at all.

Suggested fix

Recommended A: take the evidence from the segment, skip only built-in sort fields (provisional)

In Sort.getPrimarySortField, read the no-effect evidence (FieldInfos, skipper) from the unwrapped segment, FilterLeafReader.unwrap(reader), and skip only the built-in sort-field classes, whose order depends on their own field alone (+19/-5). With it, on main, all 13 repro tests and 68 neighbouring core suites pass, assertions on and off. It assumes the wrapper only hides data, so it is a provisional suggestion.

LeafReader segment = FilterLeafReader.unwrap(reader);
for (SortField sf : sort.fields) {
  String field = sf.getField();
  if (field == null || isOrderedByOwnValues(sf) == false) {
    return sf; // custom, or a key that may come from other fields
  }
  if (segment.getFieldInfos().fieldInfo(field) == null) continue;
  DocValuesSkipper skipper = segment.getDocValuesSkipper(field);
  ... // dense and minValue() == maxValue(): continue, as today
}
// isOrderedByOwnValues: sf.getClass() is SortField, SortedNumericSortField,
// SortedSetSortField or BinarySortField
All options and trade-offs5 options · 715 words

Two fixes were built and run in a scratch worktree at a9779ef3c193. A also covers custom sort fields other than BinarySortField and is the suggested one, provisionally: it was validated on main only and assumes the wrapper only hides data. B is the finder's sketch. Until a fix ships, a hiding wrapper that also returns getMetaData() without the sort gets the right hits (executed on main, a 10.6 snapshot and 10.5.2).

A: take the evidence from the segment, skip only built-in sort fields (provisional) (validated by the checks) (recommended)

Read FieldInfos and the skipper from FilterLeafReader.unwrap(reader), the segment that holds the order, and return any sort field that is not exactly SortField, SortedNumericSortField, SortedSetSortField or BinarySortField as the primary sort without trying to skip it. +19/-5 in Sort.java.

LeafReader segment = FilterLeafReader.unwrap(reader);
for (SortField sf : sort.fields) {
  String field = sf.getField();
  if (field == null || isOrderedByOwnValues(sf) == false) {
    return sf; // custom, or a key that may come from other fields
  }
  if (segment.getFieldInfos().fieldInfo(field) == null) continue;
  DocValuesSkipper skipper = segment.getDocValuesSkipper(field);
  ... // dense and minValue() == maxValue(): continue, as today
}
// isOrderedByOwnValues: sf.getClass() is SortField, SortedNumericSortField,
// SortedSetSortField or BinarySortField

Trade-offs

  • The built-in classes keep #16424's optimisation: an absent or constant field really is a no-op in a segment. Every subclass, BinarySortField subclasses included, is returned as the primary sort, so it never feeds a shortcut on another field; that only costs the optimisation, never correctness.
  • The range queries still read the queried field's values and skipper through the wrapper, as today.
  • Gap: a wrapper that is not a FilterLeafReader (a direct LeafReader subclass that delegates getMetaData()) is not unwrapped. Not benchmarked; the change adds one unwrap loop per call.
  • Assumes the wrapper only hides data, so that the segment's FieldInfos and skippers describe the sort it reports; FilterLeafReader also allows transforming wrappers (rewritten metadata, synthesized fields), and no test covers one. The exact-class allowlist changes what a public static method returns for harmless subclasses. Maintainers may prefer a more conservative rule; documenting the wrapper obligation is legitimate for the wrapper form.
  • Main only: a branch_10_6 backport needs the 10.x method's try/catch for IOException (and its annotation). No fix A run used the RC1 artifacts or the 10.6 snapshot.

What the checks validated

Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the 5 repro classes (13 tests) and 68 neighbouring core suites (TestGetPrimarySortField, TestDocValuesQueries, TestIndexSortSortedNumericDocValuesRangeQuery, TestBinarySortField, the sort tests, TestIndexSorting and every org.apache.lucene.document test): 747 tests, 0 failures, 26 skipped, with assertions on and off; on stock the same run fails exactly the 13 repro tests. :lucene:core:check -x test with the fix and the new test passes (google-java-format, ecj lint, forbidden APIs).

B: take the evidence from the segment, never skip a BinarySortField (validated by the checks)

The finder's sketch: the same unwrap, and field == null || sf instanceof BinarySortField returns the sort field as primary. +9/-5 in Sort.java.

Trade-offs

Fixes the same cases, but other custom SortField subclasses with their own index sorter, whose key need not be their named field's values, are still skipped.

What the checks validated

Gradle, assertions on: the same 747 tests, 0 failures, 26 skipped. The finder's earlier runner check: its 5 repro tests and 72 upstream tests pass.

Document a wrapper obligation

Say in FilterLeafReader (and getPrimarySortField) that a reader hiding an index-sort field must drop or rewrite getMetaData().sort().

Trade-offs

A legitimate maintainer choice for the wrapper form, and the workaround users can apply today; it leaves the BinarySortField form, which needs no wrapper. The javadoc change itself is text; the wrapper side, a reader that drops the sort, is the executed workaround.

Skip only on skipper evidence (drop the FieldInfos rule)

Remove the "not in FieldInfos" rule and keep the dense single-value skipper rule.

Trade-offs

Still misfires through the skipper rule: the independent reproducer's diagnostic shadow fixed 4 of 5 tests, and the skipper-rule test still failed. It also changes what TestGetPrimarySortField.testPrimaryWithNoValuesInSegmentIsSkipped asserts (by reading).

Revert to the first index-sort field

Make getPrimarySortField return the first sort field, the rule before #16424.

Trade-offs

The repros pass (the follow-up's revert check on main and the 10.6 snapshot), but #16424's optimisation and its TestGetPrimarySortField assertions go.

Downstream note

A field-level-security view that hides the first index-sort field but keeps the index sort in getMetaData() gets wrong range results on the next sort field from 10.6 on, some of them outside the range; dropping the sort in the wrapper avoids it.

How it was found

A hunt over the index-sort range shortcuts listed every caller of Sort.getPrimarySortField and spotted the "not in FieldInfos, so skip" rule. A metamorphic test (hide a field the query does not use, compare hits) and a derived-key probe executed it, and a bounded-exhaustive check against brute force confirmed it before the finder wrote the JUnit tests.

The method in full199 words

Method: code inventory, then metamorphic testing and bounded-exhaustive translation validation (the hunt's "semi-formal" tier).

  • The hunt unit covered the index-sort shortcuts of the doc-values range queries. Listing every consumer of Sort.getPrimarySortField over the non-sandbox sources showed the rule that a sort field missing from the reader's FieldInfos is skipped.
  • A metamorphic probe hid a field with FieldFilterLeafReader and compared the hits of range queries on the other field: 595 of 600 wrong on main and a 10.6 snapshot built from the RC1 commit, 0 of 600 on 10.5.2. A BinarySortField derived-key probe: 99 of 100 wrong on main and the 10.6 snapshot, 0 of 100 on 10.5.2.
  • Bounded-exhaustive translation validation of the stock jar against brute-force range semantics: every layout of up to 5 documents over (c, f) in {0,1} x {1,2,3}, index sort (c, f) LONG, NumericDocValuesField with a skip index, every range on f, c hidden by FieldFilterLeafReader (its FieldInfo only); the BinarySortField and LongField forms are not part of it. Plain reader 0 of 55,980 checks wrong; reader hiding c 22,120 wrong in 5,964 of 9,330 layouts; 10.5.2 and the fix sketch 0.
  • The finder then wrote the public-API JUnit tests, and independent checkers reproduced them.

Prior reports, history and checks

Prior reports

No prior report found (three search-tool runs of 59 queries each, about 40 manual GitHub searches, the file lists of 532 recent PRs and the 10.6.0 vote threads, 2026-10-28). The nearest items are #16424, which added the rules, and open #16857 and #16699, which would reuse them.

Searches4 searches

Registry verdict new: nobody has reported or discussed this bug. Nearest items: #16424 (introduced both rules; its review is about density only), open #16857 (reuses the rules for TopFieldCollector early termination) and open #16699 (FLOAT/DOUBLE sorts), #16884-#16886 and #16893 (document blocks and multi-valued fields at the same consumers, a different cause, fixed by #16893 in the RC, where the repro still fails), #16887 (TopFieldCollector with document blocks, still open), #16202 (added BinarySortField with the "other doc-values fields" wording), #16434 (a different hazard of the same method), #9311 / LUCENE-8264 (shows a wrapper can drop the sort, not that it must), and a downstream field-level-security PR that hides doc-values skippers of hidden fields (a different bug, on Lucene 10.5.2). Limits: keyword search only; GitHub does not index PR diffs (the file-list scan covers recent PRs); 9 of 18 planned web queries were throttled; private reports are invisible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; web2026-10-28a scripted prior-report search, three runs of 59 queries each (identifiers and messages; field-level-security and field-subset readers; BinarySortField, derived keys, sort pruning), plus the finder's 51-query and the devil's advocate's 56-query runsno report; nearest #16424, the introducing PR
GitHub (apache/lucene and three downstream search servers)2026-10-28about 40 manual searches; the file lists of 180 open and 352 of the 354 closed apache/lucene PRs updated since 2026-07-27 (two returned none); commits touching the five files on main, branch_10_6 and branch_10x since 2026-07-01no report; only #16857 touches the skip loop and keeps the rules
dev@ list2026-10-28the 10.6.0 RC1 vote and feature-freeze threadsno mention of this code
Web2026-10-289 general queries (9 more throttled)generic pages only

History

Introduced by #16424 (693a7234bad, merged 2026-07-30; branch_10x backport a22c06400da), so the first affected release is 10.6.0 (released 2026-10-29). Main and RC1 fail; 10.5.0 to 10.5.2, 10.4.0, 10.0.0, 9.12.3 and 9.10.0 pass. Reverting only getPrimarySortField to the first sort field makes the repro pass.

Versions tested8 rows

Introduced by #16424 "Smarter use of sort-field optimizations in DV range queries" (@romseygeek; opened 2026-07-27, approved by @sgup432, merged 2026-07-30): 693a7234bad2e03abc80c3dc9819aef493095aeb on main and the backport a22c06400da4 on branch_10x the same day, which reaches branch_10_6 through its fork on 2026-10-14. It added Sort.getPrimarySortField with both skip rules and replaced the shortcuts' first-sort-field test (indexSort.getSort()[0].getField().equals(field)) with it. git log -S for the method, the "Field has no values" comment and the skipper test finds only this commit, and git blame gives it for the method and the consumer lines. The exact parent commit was not built (its files no longer compile against a9779ef); the one-method revert stands in for it. Upstream main c5ab3e57b9b and the branch_10_6 head still have both rules.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's 3 tests, the finder's 5 and the reproducer's 5 fail, assertions on and off
10.6.0 RC1 4ae946e5a9b released as 10.6.0 on 2026-10-29the official Maven artifacts fail the finder's 5 tests; 10.6.0-SNAPSHOT jars built from the same commit fail this doc's 3, the finder's 5 and the reproducer's 5; assertions on and off
upstream main 20ced71 affectedwith the three range-query classes changed since a9779ef (#16893) shadowed in: the finder's 5 fail
10.5.0, 10.5.1, 10.5.2 last releases before 10.6.0the finder's 5 pass on each; this doc's 3 and the reproducer's 5 pass on 10.5.2 (BinarySortField exists, no getPrimarySortField)
10.4.0, 10.0.0 not affectedthe hidden-field tests pass (no BinarySortField)
9.12.3, 9.10.0 not affecteda version-neutral LongField variant passes
main and the 10.6 snapshot with getPrimarySortField returning the first sort field revert checkthe finder's 5 pass
fix A on a9779ef3c193 not affectedthe 13 repro tests and 68 neighbouring suites pass, assertions on and off

Independent checks

An independent reproducer (55 ranges against brute force), a cross-model review (reading only), an intended-behaviour devil's advocate with a complete hiding wrapper, and an adjudication: all real. Every re-run fails on main and on the RC1 commit (official artifacts or a snapshot build) with assertions on and off and passes on 10.5.2; neither cross-model reviewer ran code.

Checks and verdicts439 words

Each check ran or read on its own; the registration check and an audit re-ran the tests with new seeds, and this doc re-ran them through gradle on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder (inventory, probes, bounded-exhaustive check, JUnit) candidate5 JUnit tests fail on main (assertions on and off), the 10.6 RC and upstream main 20ced71 (shadow); pass on 10.5.2 and with the fix sketch, which also passes 72 upstream tests.
independent reproducer realOwn tests, 55 ranges each against a brute-force scan: 43/55 wrong (46/55 LongField) on main and a 10.6 snapshot, 0 on 10.5.2; controls pass (plain reader, a wrapper hiding an unrelated field, a field without a skip index); diagnostic shadows pin both skip rules.
cross-model review, intended lens real, mediumAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: root cause agrees, inputs in contract, the intended-behaviour argument moderate and failing. It ran no Java, and its record names the hunt's earlier main ea57360912ed as the Lucene head. A separate run beside it lacked the test SPI lines, so 2 of its 5 failures were setup errors; the re-runs filed with it (fail on main and a 10.6 snapshot, pass on 10.5.2) were made by the hunt.
cross-model review, refute lens not real, low (procedural)Three attempts never ran code; the reviewer calls its verdict "not a substantive refutation". A separate run beside it failed 5/5, but 2 of those were the same missing-SPI setup errors; the 3 wrapper failures are real.
intended-behaviour devil's advocate realBuilt the "wrapper must drop the sort" case and showed why it fails; a complete hiding wrapper fails the same way; a sort-dropping wrapper is a working workaround; the older sorted-search inconsistency predates 10.6.
adjudication confirmed, highRe-ran the reproducer's tests with seed C0FFEE38: 5/5 fail on main with assertions on and off, 5/5 pass on 10.5.2.
Registration check and audit reproduces; keepRegistration check, seed 5EED0328: the reproducer's tests fail 5/5 with assertions off, the finder's 5/5 with them on. Audit, seed A0D17004: the reproducer's fail 5/5 on main with assertions on and off and pass 5/5 on 10.5.2.
this doc's runs stock fails, fixes passThe new test fails 3/3 on main and a 10.6.0-SNAPSHOT build of the RC1 commit, assertions on and off, and passes on 10.5.2. Gradle on stock main fails exactly the 13 repro tests of 747 (26 skipped); with fix A 0 of 747 fail (26 skipped), assertions on and off, and the static checks pass; with fix B 0 of 747 fail.

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

Finding record

LFV-860, snapshot of 2026-11-06

Registry title
Sort.getPrimarySortField (new in 10.6.0, #16424) skips an index-sort field that the reader's FieldInfos do not list, or whose skip index holds a single value, even when that field still orders the documents (a FilterLeafReader hides it but keeps the index sort, or a BinarySortField derives its key from other fields), so doc-values range queries on the next sort field, LongField.newRangeQuery included, return wrong hits or throw
Area
search / index-sort range-query shortcuts (Sort.getPrimarySortField; SortedNumericDocValuesRangeQuery, SortedSetDocValuesRangeQuery, IndexSortSortedNumericDocValuesRangeQuery)
Priority
Medium, rank 94: A core range-query regression released in 10.6.0 that silently returns wrong hits, but only with a multi-field index sort plus a field-hiding reader or a derived-key BinarySortField.
Impact class
silent wrong results
Impact note
Applications on main or 10.6.0 (released 2026-10-29; 10.5.2 and every older release are correct) that use an index sort of at least two fields (opt-in) and either read through a field-hiding FilterLeafReader that keeps the index sort (FilterLeafReader's default delegation; the test framework's FieldFilterLeafReader and field-level-security views built like it) or sort by a BinarySortField subclass whose key comes from other doc-values fields (allowed by its javadoc) get wrong hits and counts, with no error, for most doc-values range queries on the next sort field (newSlowRangeQuery of NumericDocValuesField, SortedNumericDocValuesField and SortedDocValuesField on a skip-indexed field, and LongField.newRangeQuery, which can also throw IllegalArgumentException; IntField.newRangeQuery takes the same path [read]); no scale is needed, nothing is persisted, and a fixed jar restores correct results. On main and branch_11x (unreleased, since #16857) a field-sorted top-n search by the next index-sort field also returns wrong top hits through index-sort early termination [exec: hidden-field and both derived-key forms].
Summary
You sort an index by two fields, say (c, f), and then either read it through a FilterLeafReader that hides c from FieldInfos but, like every FilterLeafReader by default, still reports the index sort (c, f) in getMetaData(), or sort by a BinarySortField subclass whose key is derived from another doc-values field, as its javadoc allows; a doc-values range query on f should still match exactly the documents whose f is in the range, as it does on 10.5.2. On main and 10.6.0, Sort.getPrimarySortField (added by #16424) finds no FieldInfo for c (or for the BinarySortField's own name), concludes that sorting by it 'has no effect', and returns f as the primary sort, so the range queries' index-sort shortcuts binary-search a segment that is not ordered by f. The result: most ranges miss matching documents and some return documents outside the range (43 of 55 ranges wrong in the independent reproduction's test; f:[1 TO 3] returns 0 hits instead of 2), and LongField.newRangeQuery can throw IllegalArgumentException 'minDoc must be < maxDoc'.
Introduced
693a7234bad2e03abc80c3dc9819aef493095aeb (#16424 'Smarter use of sort-field optimizations in DV range queries', by romseygeek; branch_10x backport a22c06400da, which reaches branch_10_6 through its 2026-10-14 fork) [read: git log -S getPrimarySortField / -S 'Field has no values in this segment' and blame of Sort.java:137-173 and every consumer call site give only this commit; exec: reverting getPrimarySortField alone to 'first index-sort field' (the pre-#16424 rule) turns 5/5 fail into 5/5 pass on main and on branch_10_6; an exact parent-vs-commit build was not run because the touched files at those commits no longer compile against a9779ef] (2026-07-30 (author date 2026-07-30T11:37:19+01:00; merged 2026-07-30T10:37:20Z))
First affected version
10.6.0 (released 2026-10-29; the 10.6.0 RC1 artifacts, rev 4ae946e5a9b, fail 5/5 [exec]; 10.5.2 is clean [exec])
Affected versions
main / 11.0.0-SNAPSHOT since 693a7234bad [exec at a9779ef3c193, -ea and -da: 5/5 fail; upstream main 645d4fe14a2, which has #16893: finder and the independent reproduction 5/5 fail, -ea and -da, 2026-10-29]; branch_10x since a22c06400da [read]; branch_10_6 head = 10.6.0 RC1 4ae946e5a9b [exec: RC1 Maven artifacts and snapshot jars, -ea and -da, 5/5 fail]. Not affected [exec]: 10.5.2, 10.5.1, 10.5.0 (5/5 pass), 10.4.0 and 10.0.0 (the 3 hidden-field tests pass; BinarySortField is absent), 9.12.3 and 9.10.0 (a version-neutral LongField leg passes). Every release before 10.6.0 lacks getPrimarySortField; its range shortcuts used only getSort()[0]. Recheck 2026-11-02: main e1faeba08bd [exec, -ea and -da: minimal 1/1, finder 5/5 and the independent reproduction 5/5 fail, plus the sorted-search early-termination leg in all three trigger forms]; branch_11x da3ea39fa59 [read: Sort.java and FieldValueHitQueue.java identical to main]; the 10.6.0 release jars fail the minimal repro with -ea and -da [exec]; the early-termination leg is main/branch_11x only (10.6.0 passes it [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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record, verdict new: three scripted prior-report searches of 59 queries each over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus the finder's 51-query and the intended-behaviour check's 56-query runs; manual GitHub searches over apache/lucene and three downstream search servers; file lists of all 180 open and 354 closed apache/lucene PRs updated since 2026-07-27; commits touching Sort.java and the three range-query classes on main, branch_10_6 and branch_10x since 2026-07-01, with author dates; the 10.6.0 RC1 vote and feature-freeze threads on dev@; a later re-check: getPrimarySortField issues 0, PRs only #16424/#16434/#16411, no PR or issue created since 2026-10-26 touches it, open #16857 unchanged since 2026-10-27, last commit to Sort.java on main and branch_10_6 is still #16424). Related, not this bug: #16424 (introduced the rules; its one review thread is about density), #16857 (merged 2026-10-29 as 70a428b5ba9 on main and 203699279f5 on branch_11x; not on branch_10_6; it reuses the same skip rule for TopFieldCollector early termination, which widened the reach: see the recheck of 2026-11-02), open #16699 (extends the shortcut to FLOAT/DOUBLE sorts: widens it), #16884-#16886 (document blocks and multi-valued fields at the same doc-values range consumers, different cause; fixed by #16893: ffa9d816eeb on main, afeeeff2d14 on branch_10_6, in 10.6.0) and #16887 (salvatorecampagna; document blocks in TopFieldCollector index-sort early termination, a different consumer and cause; fixed by #16947 (romseygeek; merged 2026-10-30 as f6e64aa0db3 on main and e5c8f72f00f on branch_11x; not on branch_10_6 or in 10.6.0)), #16202 (added BinarySortField with the 'other doc-values fields' wording), and a downstream field-level-security fix that hides doc-values skippers of hidden fields (elastic/elasticsearch#163541, different bug, downstream on Lucene 10.5.2). Recheck 2026-10-29 (read-only): #16893 merged on main as ffa9d816eeb (blocks and multi-valued guards only, closes #16884-#16886; not this bug); #16857 still open, its 2026-10-28 review thread concerns hasBlocks early termination, not the skip rules; #16699 open, unchanged; branch_10x has no #16893 backport. Recheck 2026-11-02 (main e1faeba08bd, read and executed): since #16857, FieldValueHitQueue.canEarlyTerminate(SortField[], LeafReader) uses Sort.primarySortFieldIndex with the same no-op rule, so a field-sorted top-n search by the next index-sort field also returns wrong top hits on main and branch_11x [exec on e1faeba, -ea and -da; correct on 645d4fe14a20 and 10.6.0]; #16947 removes that reach only on segments with document blocks; the Sort.java skip rules are unchanged; no issue or PR reports this bug.
Lucene commit tested
main a9779ef3c193 (the repro runner: this doc's test, the finder's and an independent reproducer's, assertions on and off; gradle in a scratch worktree: stock and fixes A and B over the 13 repro tests and 68 neighbouring upstream suites, assertions on and off, and the core module's static checks with fix A); the 10.6.0 RC1 commit 4ae946e5a9b (the official Maven artifacts with the finder's tests, and a 10.6.0-SNAPSHOT build of that commit with every test); the release jars 10.5.2, 10.5.1, 10.5.0, 10.4.0, 10.0.0, 9.12.3 and 9.10.0; upstream main 20ced71 with the changed range-query classes shadowed in. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:138-177 (at a9779ef3c193) (getPrimarySortField; javadoc :141-143 'If sorting against a field would have no effect then that SortField is skipped over ... fields with no values in the segment, or fields that have only a single distinct value'; :156-159 'if (reader.getFieldInfos().fieldInfo(field) == null) { // Field has no values in this segment ... continue; }'; :160-169 dense skipper with minValue() == maxValue() -> continue)
  • lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesRangeQuery.java:144, (at a9779ef3c193) :237-247 (canUsePrimarySortShortcut takes getPrimarySortField's answer when its field equals the queried field)
  • lucene/core/src/java/org/apache/lucene/document/SortedSetDocValuesRangeQuery.java:126, (at a9779ef3c193) :288-298 (same shortcut for SORTED/SORTED_SET)
  • lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:40-56 (at a9779ef3c193) (javadoc: 'its primary sort is on the same field as the query'; 'This fallback must be an equivalent range query -- it should produce the same documents'), :198, :425, :523 (getPrimarySortField in count, BKD and doc-values paths)
  • lucene/core/src/java/org/apache/lucene/document/LongField.java:144 (at a9779ef3c193) and IntField.java:141 (newRangeQuery returns IndexSortSortedNumericDocValuesRangeQuery); NumericDocValuesField.java:91-92 ('matches all documents whose value is between lowerValue and upperValue included'), :111
  • lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:40-45, (at a9779ef3c193) :150-152 (a subclass may derive the key 'from the stored bytes and/or other doc-values fields of the segment')
  • lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:30-31 (at a9779ef3c193) ('possibly transforming the data along the way'), :477-481 (getMetaData delegates); index/LeafMetaData.java:32 (sort = 'the order in which documents from this index are sorted'); lucene/test-framework/src/java/org/apache/lucene/tests/index/FieldFilterLeafReader.java:41-44
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:138-173 (at a9779ef3c193) (javadoc :141-143; FieldInfos rule :156-159; skipper rule :164-169)
  • lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesRangeQuery.java:144, (at a9779ef3c193) 237-247; document/SortedSetDocValuesRangeQuery.java:126, 288-298
  • lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:198, (at a9779ef3c193) 425, 504, 523
  • lucene/core/src/java/org/apache/lucene/document/SortedSkipperScorerSupplier.java:113-182 (at a9779ef3c193) (the range ends)
  • lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:41-44; (at a9779ef3c193) index/FilterLeafReader.java:477-481; index/LeafMetaData.java:32
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:110-111, (at a9779ef3c193) 145; document/LongField.java:138-144; document/IntField.java:135-141
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/FieldFilterLeafReader.java:40-44 (at a9779ef3c193)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:141-143, (at a9779ef3c193) 157; search/IndexSortSortedNumericDocValuesRangeQuery.java:46, 55; document/NumericDocValuesField.java:91-92
  • lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:41-44, (at a9779ef3c193) 150-152; index/FilterLeafReader.java:30-31, 37-42, 477-481; index/IndexingChain.java:1635
  • lucene/CHANGES.txt:645-646; (at a9779ef3c193) lucene/core/src/test/org/apache/lucene/search/TestGetPrimarySortField.java:137
Suggested fix
  • lucene/core/src/test/org/apache/lucene/search/TestGetPrimarySortField.java:137-158 (at a9779ef3c193)

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 repro's example, made in parallel with this doc from the probe trace of the same five documents.

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.