All findings · Lucene finding · LFV-1037 · string-sort-missing-value-ctors-skip-sentinel-check

String sort fields accept any missing value, silently treat it as STRING_FIRST, and an index sort with one cannot be reopened

Since 10.4, SortedSetSortField, KeywordField.newSortField and SortField(STRING_VAL) accept a missing value such as "zzz", which string sorts do not support. Documents without a value silently sort as with STRING_FIRST, and as an index sort the value is dropped on write, so the next IndexWriter with the same index sort refuses the index.

Summary

Priority
Low, rank 279A released core sort-API regression that only bites callers who pass an unsupported custom missing value, giving a silent missing-first order or a loud, recoverable index-sort reopen failure.
Impact
wrong error
Since
10.4.0 (#15483, merged 2025-12-08)introduced 2025-12-08
Affected
10.4.0 to 10.6.0 (the latest release) and main
Status
documented
Found by
a migration probe, then a differential check over the string sort fields

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

A string sort's missing value says where documents without a value go: STRING_FIRST or STRING_LAST. Three entry points added in 10.4 accept any other value, ignore it, and drop it from a stored index sort.

Diagram of LFV-1037: a KeywordField sort with missing value "zzz" is accepted; documents apple, banana and one without a tag sort as (none), apple, banana, the same as STRING_FIRST; as an index sort the segment stores the sort without the value, and a new IndexWriter with the same sort (in a fresh config) throws "cannot change previous indexSort".
The diagram follows the steps' example: the sort built with "zzz", the order it really gives, the index sort stored without the value, and the second IndexWriter that refuses the index. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three documents with tags "apple", "banana" and none. Sort them by KeywordField.newSortField("tag", false, MIN, "zzz"), the way you would give a numeric sort a substitute value.
  2. You expect an IllegalArgumentException. String sorts support only STRING_FIRST and STRING_LAST: new SortField("tag", Type.STRING, false, "zzz") throws, and so did the setMissingValue these constructors replaced.
  3. Instead the value is accepted and ignored. toString() shows missingValue=zzz, but the order is (none), apple, banana, as with STRING_FIRST; STRING_LAST gives apple, banana, (none).
  4. Make it the index sort (the probe indexes "apple" and the document without a tag). The first IndexWriter writes the segment with its sort stored as <sortedset: "tag"> selector=MIN, without the value. The next IndexWriter, given a fresh IndexWriterConfig with the same sort, throws cannot change previous indexSort. In the probe nothing was lost: with the value removed from the sort, the index opens.

Who is affected

Code that passes a custom string or BytesRef as the missing value of a STRING_VAL sort, a SortedSetSortField or KeywordField.newSortField, on 10.4.0 to 10.6.0 or main, with the default codec and config. Search silently sorts missing documents first; as an index sort, the next writer refuses the index. Rare: documented usage passes the two markers.

Mechanism, terms and numbers504 words

For string sorts Lucene supports two missing values, SortField.STRING_FIRST and SortField.STRING_LAST. #15483 (10.4.0) replaced the validating setMissingValue with constructor parameters and kept the check only for Type.STRING. Since then new SortField(f, Type.STRING_VAL, reverse, x), new SortedSetSortField(f, reverse, selector, x) and KeywordField.newSortField(f, reverse, selector, x) store any x. The comparators and the index sorter of these fields only ask missingValue == STRING_LAST, so x acts as STRING_FIRST, while getMissingValue() and toString() report x.

As an index sort, SortedSetSortField.serialize writes "no missing value" for anything but the two sentinels. SortField.equals compares the missing value, so the next IndexWriter, opened with a fresh config holding the same sort, refuses the index with IllegalArgumentException: cannot change previous indexSort. In the probe no documents were lost, and removing the value from the sort opens the index again. Releases up to 10.3.2 are not affected, and on 10.4 to 10.6 the deprecated setter still rejects the value; on main (11.0) the constructors are the only path.

Terms

  • Missing value: the fourth argument of new SortField(field, type, reverse, missingValue), new SortedSetSortField(field, reverse, selector, missingValue) and KeywordField.newSortField(field, reverse, selector, missingValue). It decides where documents without a value in the field sort. Numeric sorts take a substitute number; string sorts take one of two markers, SortField.STRING_FIRST or SortField.STRING_LAST (SortField.java:309-324). null means the default, which sorts like STRING_FIRST.
  • STRING_VAL: a SortField type that compares the BytesRef values of a binary doc-values field. SortedSetSortField sorts a sorted-set field, which is what KeywordField.newSortField returns.
  • Index sort: IndexWriterConfig.setIndexSort(sort) makes every segment store its documents in that order. Each segment records the sort it was written with, and a new IndexWriter checks that its config's sort matches every existing segment's.

What goes wrong

  1. SortField.validateField rejects a non-marker value only if (type == Type.STRING) (SortField.java:343-348). The SortedSetSortField constructor passes Type.CUSTOM to super and checks nothing itself (SortedSetSortField.java:74-81); KeywordField.newSortField passes the value on (KeywordField.java:188-193).
  2. The comparators read only missingValue == STRING_LAST (SortField.java:521-523, SortedSetSortField.java:175-176), and so does the index sorter, both when a new segment is sorted at flush (IndexSorter.java:512-519) and at merge (:488-493): any other value sorts like STRING_FIRST.
  3. SortedSetSortField.serialize writes 1 for STRING_FIRST, 2 for STRING_LAST and 0 otherwise (SortedSetSortField.java:124-135), so the stored sort has no missing value.
  4. SortField.equals compares the missing value (SortField.java:451-461), so IndexWriter.validateIndexSort finds the config's sort unequal to the stored one and throws (IndexWriter.java:1213-1239). Each writer needs its own IndexWriterConfig with the same sort; reusing the first writer's config fails earlier, with IllegalStateException: do not share IndexWriterConfig instances across IndexWriters (IndexWriter.java:935-947). Within the first writer session nothing fails: two commits and a forceMerge(1) succeed.

The same comparison runs in addIndexes(Directory...) and addIndexes(CodecReader...) (IndexWriter.java:3152-3158, :3228-3250): adding an index written with the "zzz" sort to a writer with that sort is refused with cannot change index sort through both (run by the fact check).

Exposure and likelihood212 words
  • Who is hit. Callers that expect a substitute value, as the numeric factories document one: IntField.newSortField's missingValue is "a sort value to use for documents with no value in the field" (IntField.java:184). A downstream project's sort DSL lets users pass exactly such a value for string fields, and its tracker (HSEARCH-3254, 2018) notes that it relied on Lucene to throw.
  • Default or opt-in. The default codec, IndexWriterConfig and searcher; any index size; assertions on or off. The trigger is the caller's own argument.
  • Effect. At search time the argument is silently ignored. As an index sort, the first writer session succeeds and writes segments; only the next IndexWriter, with a fresh config holding the same sort, throws cannot change previous indexSort. In the executed probes no documents were lost, CheckIndex was clean, and with the value removed from the sort the index opens.
  • Releases. 10.4.0 to 10.6.0 (the latest release, published 2026-10-29) and main. On 10.4 to 10.6 only callers that moved from the deprecated setter to the constructors are hit; on main the constructors are the only way to set a missing value.
  • How likely. Rare: the documented markers, every upstream test and the downstream migrations that were read (Elasticsearch, Solr) pass STRING_FIRST/STRING_LAST.

Minimal repro

One JUnit class in lucene/core, public API only, two methods, each with one expectThrows: one per fix site (SortField with STRING_VAL, and the SortedSetSortField constructor that KeywordField.newSortField calls). Each expects an IllegalArgumentException for the missing value "zzz". No index; deterministic; one thread.

TestStringSortMissingValue.java (lucene/core, package org.apache.lucene.search)35 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 org.apache.lucene.tests.util.LuceneTestCase;

// Expected: IAE as for Type.STRING (only STRING_FIRST/STRING_LAST are valid); actual: accepted and treated as STRING_FIRST.
public class TestStringSortMissingValue extends LuceneTestCase {

  public void testStringValRejectsNonSentinelMissingValue() {
    expectThrows(
        IllegalArgumentException.class,
        () -> new SortField("f", SortField.Type.STRING_VAL, false, "zzz"));
  }

  public void testSortedSetRejectsNonSentinelMissingValue() {
    expectThrows(
        IllegalArgumentException.class,
        () -> new SortedSetSortField("f", false, SortedSetSelector.Type.MIN, "zzz"));
  }
}
Command
cp $REPRO/TestStringSortMissingValue.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestStringSortMissingValue -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
Both constructors throw IllegalArgumentException, as new SortField("f", Type.STRING, false, "zzz") does.
Observed
Both accept "zzz": 2 tests, 2 failures, "Expected exception IllegalArgumentException but no exception was thrown", with assertions on and off.

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

More files, environment, full output1 more file (101 lines) · 796 words

Two methods, because one method would stop at its first expectThrows. The test shows only that the constructors accept the value; it does not show the search order or the reopen failure. The test does not compile on 10.3.2 or earlier, where the constructors do not exist. To add it to Lucene as the regression test, wrap its 124-character comment line, which google-java-format flags.

The user-visible effects come from a separate print-only probe on the public API (three documents, a KeywordField sort with "zzz", then the same sort as the index sort and a second IndexWriter); its output is in the full output below. It is supporting evidence, not the repro.

StepsProbe.java (print-only probe behind the steps; not the repro)

A plain main() on the public API with a ByteBuffersDirectory: the search order for "zzz", STRING_FIRST and STRING_LAST, and the index-sort reopen with "zzz", with the value removed, and with STRING_LAST.

StepsProbe.java (print-only probe behind the steps; not the repro)101 lines
package org.apache.lucene.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.KeywordField;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;

/** Print-only probe for the doc's steps (not the repro): search order and index-sort reopen. */
public class StepsProbe {

  public static void main(String[] args) throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(doc("apple"));
        w.addDocument(doc("banana"));
        w.addDocument(doc(null));
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        IndexSearcher s = new IndexSearcher(r);
        for (Object mv : new Object[] {"zzz", SortField.STRING_FIRST, SortField.STRING_LAST}) {
          SortField sf = KeywordField.newSortField("tag", false, SortedSetSelector.Type.MIN, mv);
          System.out.println(
              "search missing=" + mv + " sortField=" + sf + " getMissingValue=" + sf.getMissingValue()
                  + " order=" + order(s, sf));
        }
        SortField sv = new SortField("tag", SortField.Type.STRING_VAL, false, "zzz");
        System.out.println("STRING_VAL accepted: " + sv);
      }
    }
    // Within one writer session: two flushed segments merged into one, with the "zzz" index sort.
    Sort zzz = new Sort(KeywordField.newSortField("tag", false, SortedSetSelector.Type.MIN, "zzz"));
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setIndexSort(zzz))) {
        w.addDocument(doc("banana"));
        w.commit();
        w.addDocument(doc(null));
        w.addDocument(doc("apple"));
        w.commit();
        w.forceMerge(1);
        w.commit();
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < r.maxDoc(); i++) {
          if (sb.length() > 0) sb.append(", ");
          sb.append(r.storedFields().document(i).get("name"));
        }
        System.out.println(
            "same session, 2 commits + forceMerge(1): OK, segments=" + r.leaves().size()
                + " stored order=" + sb);
      }
    }
    for (Object mv : new Object[] {"zzz", SortField.STRING_LAST}) {
      Sort sort = new Sort(KeywordField.newSortField("tag", false, SortedSetSelector.Type.MIN, mv));
      try (Directory dir = new ByteBuffersDirectory()) {
        try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setIndexSort(sort))) {
          w.addDocument(doc("apple"));
          w.addDocument(doc(null));
        }
        try (DirectoryReader r = DirectoryReader.open(dir)) {
          System.out.println(
              "indexSort missing=" + mv + " config=" + sort + " segment="
                  + r.leaves().get(0).reader().getMetaData().sort());
        }
        try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setIndexSort(sort))) {
          System.out.println("reopen missing=" + mv + ": OK");
        } catch (IllegalArgumentException e) {
          System.out.println("reopen missing=" + mv + ": IllegalArgumentException: " + e.getMessage());
          Sort plain = new Sort(KeywordField.newSortField("tag", false, SortedSetSelector.Type.MIN));
          try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setIndexSort(plain))) {
            System.out.println("reopen with the value removed (" + plain + "): OK");
          }
        }
      }
    }
  }

  private static Document doc(String tag) {
    Document d = new Document();
    d.add(new StoredField("name", tag == null ? "(none)" : tag));
    if (tag != null) {
      d.add(new KeywordField("tag", tag, Field.Store.NO));
    }
    return d;
  }

  private static String order(IndexSearcher s, SortField sf) throws Exception {
    TopDocs td = s.search(MatchAllDocsQuery.INSTANCE, 10, new Sort(sf));
    StringBuilder sb = new StringBuilder();
    for (ScoreDoc sd : td.scoreDocs) {
      if (sb.length() > 0) sb.append(", ");
      sb.append(s.storedFields().document(sd.doc).get("name"));
    }
    return sb.toString();
  }
}

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.4.0, 10.5.2 and 10.6.0 release jars (10.3.2: compile error, the constructors do not exist)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestStringSortMissingValue.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestStringSortMissingValue -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

# gradle, main 645d4fe14a20, -Ptests.asserts=true (the same with false, and on the 10.4.0, 10.5.2 and 10.6.0 jars)
TestStringSortMissingValue > testStringValRejectsNonSentinelMissingValue FAILED
    junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:1812F997AAA093B0]:0)
        ...
        at app//org.apache.lucene.search.TestStringSortMissingValue.testStringValRejectsNonSentinelMissingValue(TestStringSortMissingValue.java:25)
TestStringSortMissingValue > testSortedSetRejectsNonSentinelMissingValue FAILED
    junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
        ...
        at app//org.apache.lucene.search.TestStringSortMissingValue.testSortedSetRejectsNonSentinelMissingValue(TestStringSortMissingValue.java:31)
:lucene:core:test (FAILURE): 2 tests, 2  failures

# print-only probe (StepsProbe), main 645d4fe14a20, assertions off
search missing=zzz sortField=<sortedset: "tag"> missingValue=zzz selector=MIN getMissingValue=zzz order=(none), apple, banana
search missing=SortField.STRING_FIRST ... order=(none), apple, banana
search missing=SortField.STRING_LAST ... order=apple, banana, (none)
STRING_VAL accepted: <string_val: "tag"> missingValue=zzz
same session, 2 commits + forceMerge(1): OK, segments=1 stored order=(none), apple, banana
indexSort missing=zzz config=<sortedset: "tag"> missingValue=zzz selector=MIN segment=<sortedset: "tag"> selector=MIN
reopen missing=zzz: IllegalArgumentException: cannot change previous indexSort=<sortedset: "tag"> selector=MIN (from segment=_0(11.0.0):c2) to new indexSort=<sortedset: "tag"> missingValue=zzz selector=MIN
reopen with the value removed (<sortedset: "tag"> selector=MIN): OK
reopen missing=SortField.STRING_LAST: OK

Why it looks unintended

  • The same check exists one if away: Type.STRING rejects "zzz", added by the same PR with a test (TestSort#testStringMissingValues).
  • The removed setMissingValue rejected it for STRING, STRING_VAL and SortedSetSortField, and BinarySortField and ToParentBlockJoinSortField reject it today.
  • IndexWriter accepts the config, writes segments with it, then refuses that index under a fresh config with the same sort.
The full argument227 words
  • The contract names two values. STRING_FIRST/STRING_LAST: "Pass this as a missingValue constructor parameter to have missing string values sort first" / "last" (SortField.java:309-324). SortedSetSortField: "Like sorting by string, this also supports sorting missing values as first or last, via SortField#STRING_FIRST or SortField#STRING_LAST" (SortedSetSortField.java:41-42).
  • Same check, one type over. #15483 added the rejection for Type.STRING to validateField with TestSort#testStringMissingValues, which asserts "For Type.STRING, missing value must be either STRING_FIRST or STRING_LAST" (TestSort.java:94-104).
  • The replaced API. 10.5.2 SortField.setMissingValue threw "For STRING type, missing value must be either STRING_FIRST or STRING_LAST" when type == Type.STRING || type == Type.STRING_VAL; SortedSetSortField.setMissingValue ("Note that this must be STRING_FIRST or STRING_LAST") threw "For SORTED_SET type, ...". The checks date from 2014; executed on 10.4.0, 10.5.2 and 10.6.0 RC1, the setter still rejects "zzz".
  • Siblings. BinarySortField: "@param missingValue STRING_FIRST, STRING_LAST, or null" plus a constructor check (BinarySortField.java:72-73, :95-98); ToParentBlockJoinSortField rejects too (ToParentBlockJoinSortField.java:178-198), added after a different #15483 side effect (#15548 / #15727).
  • Migration text. "SortField.setMissingValue() has been removed. Missing values should be configured in SortField constructor methods, as they are now final", with a SortedSetSortField example passing SortField.STRING_LAST (MIGRATE.md:258-278). The captured PR metadata and review summaries, the #15483 CHANGES entry and MIGRATE.md announce no widening of the accepted values; #15483's goal was immutability.
The devil's-advocate check147 words

The strongest case (the cross-model lenses, the independent reproduction, the intended-behaviour check and the adjudication): the new constructors and KeywordField.newSortField document no @param or @throws for missingValue, so a non-marker value is unsupported input that Lucene never promised to reject; and 10.4 to 10.6 accept it, so a check changes behaviour.

Why it fails: the missing @param is a real gap (fix A adds it), but the only documentation of the parameter names the two markers; the same code, the removed setters and two siblings reject the same input; nothing in the captured PR history suggests the check was dropped on purpose; and "unsupported input" does not explain an IndexWriter that accepts a config once and then refuses the index it produced. The compatibility point bears on where a fix lands (main / 11.x looks lower-risk; the maintainers decide), not on whether the behaviour is intended.

Suggested fix

Recommended Reject non-marker values in both constructors

Reject anything but null, STRING_FIRST and STRING_LAST in SortField.validateField for STRING_VAL too, and in the SortedSetSortField constructor (which KeywordField.newSortField calls), and document missingValue (3 files, +13/-2). The test passes with it, and so do 13 sort and index-sorting suites (193 tests), with assertions on and off.

// SortField.validateField
if (type == Type.STRING || type == Type.STRING_VAL) {
  if (missingValue != null && missingValue != STRING_FIRST && missingValue != STRING_LAST) {
    throw new IllegalArgumentException(
        "For Type." + type + ", missing value must be either STRING_FIRST or STRING_LAST");
  }
}
// SortedSetSortField(String, boolean, SortedSetSelector.Type, Object), after super(...)
if (missingValue != null && missingValue != STRING_FIRST && missingValue != STRING_LAST) {
  throw new IllegalArgumentException(
      "For SortedSetSortField, missing value must be either STRING_FIRST or STRING_LAST");
}
All options and trade-offs4 options · 516 words

Option A was built and run in a scratch worktree of 645d4fe14a20; it also applies, with line offsets, to upstream main 716d6f216f52. The other options were not built.

Reject non-marker values in both constructors (validated by the checks) (recommended)

The finder's two checks plus the missing @param missingValue javadoc on the three entry points. The Type.STRING message is unchanged, so TestSort#testStringMissingValues still passes.

// SortField.validateField
if (type == Type.STRING || type == Type.STRING_VAL) {
  if (missingValue != null && missingValue != STRING_FIRST && missingValue != STRING_LAST) {
    throw new IllegalArgumentException(
        "For Type." + type + ", missing value must be either STRING_FIRST or STRING_LAST");
  }
}
// SortedSetSortField(String, boolean, SortedSetSelector.Type, Object), after super(...)
if (missingValue != null && missingValue != STRING_FIRST && missingValue != STRING_LAST) {
  throw new IllegalArgumentException(
      "For SortedSetSortField, missing value must be either STRING_FIRST or STRING_LAST");
}

Trade-offs

  • Restores the pre-10.4 behaviour and matches Type.STRING, BinarySortField and ToParentBlockJoinSortField.
  • Code on 10.4 to 10.6 that passes such a value gets an IllegalArgumentException after upgrading, so main / 11.x looks like the lower-risk target; where it lands and any 10.6.x backport are the maintainers' decision.
  • An index already written with such a value has missing-first segments that store no missing value. To keep opening it, the application must rebuild the sort with null as the missing value (run). Keeping the old value, or swapping in STRING_FIRST, is refused: STRING_FIRST sorts the same but is not equal to the stored sort (run by the fact check).

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, JDK 25. Stock: the test fails 2 of 2. With the fix: the test passes, with assertions on and off.
  • With the fix: 13 suites, 193 tests, 0 skipped, 0 failures, with assertions on and off: TestSort, TestSortOptimization, TestSortRandom, TestSortRescorer, TestSortedNumericDocValuesRangeIterator, TestSortedNumericSortField, TestSortedSetSelector, TestSortedSetSortField, TestSearchAfter, TestIndexSorting, TestKeywordField, TestSegmentReordering and the new test.
  • :lucene:core:check -x test (google-java-format, ecjLint, forbiddenApis) passes once the test's 124-character comment line is wrapped; google-java-format flags only that line, not the fix.
  • The diff applies, with line offsets, to upstream main 716d6f216f52 (not built there). Not run: the full test suite, nightly tests, other modules.

One shared helper

A small static SortField check used by validateField (STRING, STRING_VAL), SortedSetSortField, BinarySortField and ToParentBlockJoinSortField.

Trade-offs

Same behaviour as A with one message, but touches more files and the join module, and changes the existing sibling messages (for example BinarySortField's "missing value for BinarySortField must be null, STRING_FIRST or STRING_LAST"). Not built.

Document only

Add @param missingValue ... STRING_FIRST or STRING_LAST and leave the code.

Trade-offs

The silent search order and the late reopen failure stay for callers who miss the doc. Not recommended alone.

Honour a real substitute value (a feature)

Sort documents without a value as if they had the given string, as numeric sorts do.

Trade-offs

Needs a comparator and index-sorter rule for a term that may not exist in the segment, and a new SortedSetSortField serialization. Much larger; the old setters never allowed it.

Downstream note

Products that map a user's "missing values" sort option onto these fields should pass only STRING_FIRST, STRING_LAST or null; any other string is silently ignored.

How it was found

A probe that compares old and new APIs after a migration showed new SortField("s", STRING_VAL, false, "zzz") accepted on main while the 10.6.0 RC1 setter rejects "zzz". A differential check over the string sort fields (5 constructors, 7 missing values, both directions) then found the SortedSetSortField leg and the reopen failure.

The method in full157 words
  • First signal (not formal). A planner's migration probe (2026-10-29) compared setMissingValue, deprecated on 10.x and removed on main, with the constructors that replace it.
  • Differential check. The finder (a hunt unit) ran 70 cells: SortField(STRING), SortField(STRING_VAL), SortedSetSortField, BinarySortField and ToParentBlockJoinSortField, each with null, STRING_FIRST, STRING_LAST, "zzz", "", a BytesRef and an Integer, forward and reversed. Each cell checked acceptance and the position of the document without a value and, where the field can be an index sort, the stored index sort and a reopen with the same sort. KeywordField.newSortField was not a separate row; it calls the SortedSetSortField constructor (KeywordField.java:188-193), and the steps probe uses it. A second differential compared the setter with the constructor on 10.4.0, 10.5.2, 10.6.0 RC1 and main.
  • Reduction. The no-noise gate reduced it to one JUnit class with two methods, each with one expectThrows, one per fix site.

Prior reports, history and checks

Prior reports

No prior report: about 100 queries on 2026-10-29 (GitHub issues, PRs and commits, four mailing lists, the JIRA archive, downstream trackers, the web) and a refresh on 2026-10-30. Related: #15483 and #15486 made the gap; #15548 / #15727 fixed a different #15483 side effect.

Searches4 searches

Verdict new. Related, not this bug: #15483 (introduced the gap), #15486 (removed the rejecting setters), #15480 (open umbrella "Make SortField immutable": the constructors "could also enhance type safety"), #15548 / #15727 (ToParentBlockJoinSortField, a different class and fix site), #16202 (BinarySortField, which validates), #16857 (merged 2026-10-29, edits the same files for other reasons; a fix PR rebases over it), HSEARCH-3254 / HSEARCH-2332 (downstream records that Lucene used to reject such values). GitHub search tokenizes STRING_FIRST/STRING_LAST poorly, so those terms were covered through other phrasings, the open-PR file scan and the list searches.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), JIRA archive, Elasticsearch, OpenSearch, Solr, the web2026-10-29two scripted prior-report searches (52 and 51 queries) plus manual searches: STRING_VAL, SortedSetSortField, missingValue / "missing value", newSortField, the exact error messages, references to #15483, every issue and PR created since 2026-10-27no report of this defect
Open PRs by file2026-10-29changed files of the 196 open PRs updated since 2026-05-01#16857, #16276 and #15752 touch SortField.java / SortedSetSortField.java for other reasons; none changes these checks
Mailing lists (lists.apache.org) and Hibernate Search's GitHub and JIRA2026-10-29issues@, dev@, java-user@, with the date range 2025-12-01 to 2026-10-30, run 2026-10-29 (later messages not covered; the 2026-10-30 refresh was GitHub only)no report; HSEARCH-3254 and HSEARCH-2332 record the old rejecting behaviour
GitHub refresh2026-10-30all issues and PRs created since 2026-10-29; SortedSetSortField, "missing value", STRING_VAL, newSortField updated since 2026-10-28; "cannot change previous indexSort"; commits on the fix filesnothing related; the only new commit on the fix files is #16857, which does not touch the bug's lines

History

Introduced in 10.4.0 by #15483 (75108f430664, merged 2025-12-08), which moved missing values into constructors and kept the check only for Type.STRING; #15486 then removed the setter on main. Affects 10.4.0 to 10.6.0 and main; 10.3.2 and older are clean.

Versions tested7 rows
  • 2014: setMissingValue starts rejecting non-marker string values (LUCENE-5399, LUCENE-5835 for STRING_VAL, LUCENE-5454 / LUCENE-5666 for SortedSetSortField).
  • 9.0.0: LUCENE-9330 (de2bad903905) gives SortedSetSortField.serialize its marker-only encoding; harmless while the setter enforced the markers.
  • 2025-12-08: #15483 "Deprecate SortField.setMissingValue()" (romseygeek, approved by benwtrent), 75108f430664 on main, backport 202f206c2f97; first release 10.4.0.
  • 2025-12-09: #15486 (f4f6d70c0c4c, main only) removes the setter; on main nothing rejects the value any more.
  • 2026-10-29: upstream main 716d6f216f52 keeps the same checks, predicates and serialize (read; #16857 changed nearby comparator code); 10.6.0 is released with the bug.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails 2 of 2, assertions on and off (gradle and standalone)
10.6.0 (latest release, the RC1 revision 4ae946e5a9b9) affected2 of 2 fail, assertions on and off (release jars)
10.5.2, 10.4.0 affected2 of 2 fail, assertions off (release jars)
10.5.0, 10.5.1 readcontain 202f206c2f97, same code
10.3.2, 9.12.3 API absentthe test does not compile; setMissingValue("zzz") is rejected
upstream main 716d6f216f52, branch_11x readthe same checks, predicates and serialize; #16857 changed nearby comparator code and shifted line numbers (not built or run)
with fix A (main 645d4fe14a20) not affectedthe test passes, assertions on and off

Independent checks

The finder, the independent reproduction, the intended-behaviour check, an adjudication and an independent reviewer from another model family (OpenAI GPT-5.6, executing, in lenses) all rate it real; the adjudication confirmed it (high, severity Low) after re-running the independent reproduction's test. Each re-ran a repro on the stock jars.

Checks and verdicts296 words

The cross-model reviews ran on main ea57360912ed, where the cited files are identical to 645d4fe14a20; a separate run re-ran the reviewer's test on 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidate70-cell differential and setter-vs-constructor differential; JUnit fails on main (-ea, -da), 10.4.0, 10.5.2, 10.6.0 RC1 and branch_10x; the fix passes, with 115 upstream tests.
cross-model review, intended lens real, high; intended argument moderateOwn 3-test JUnit (acceptance, missing-first order against a STRING_LAST control, sibling rejection, the reopen exception) on main and 10.5.2; 3 of 3 and 100 upstream tests pass with the fix.
Independent reproducer realOwn 7-test JUnit fails 4 of 7 on main, 10.5.2 and 10.4.0; its 3 controls pass.
Cross-model adversary, refute lens real, high; unable to refuteOwn test fails 3 of 4 (the STRING_LAST control passes); agrees on the root cause and the fix.
Devil's advocate real, LowNo intended, documented, misuse or unreachable argument survives.
adjudication confirmed, high; wrong error, severity LowRe-ran the independent reproduction's test on main (-da): 7 run, 4 failed, controls pass.
fact checks (2026-10-30) real; wording fixes appliedA separate AI tool re-ran the minimal test (gradle and the standalone runner on main, 10.4.0, 10.5.2 and 10.6.0), the steps probe and fix A, and ran three claims this doc had only read (the STRING_FIRST reopen and both addIndexes): all throw as predicted. An independent reviewer from another model family (OpenAI GPT-5.6, read-only) found wording errors only; all are resolved.
this doc as aboveThe minimal test on main and on the 10.4.0, 10.5.2 and 10.6.0 jars; the steps probe; fix A in a scratch worktree; the prior-art refresh.

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

Finding record

LFV-1037, snapshot of 2026-11-06

Registry title
SortField(STRING_VAL), SortedSetSortField and KeywordField.newSortField accept a missing value other than STRING_FIRST/STRING_LAST and silently treat it as STRING_FIRST; as an index sort the value is lost when the segment is written, so a new IndexWriter with the same config refuses the index ('cannot change previous indexSort')
Area
search / sorting (SortField STRING_VAL, SortedSetSortField and KeywordField.newSortField missing values; index sorting)
Priority
Low, rank 279: A released core sort-API regression that only bites callers who pass an unsupported custom missing value, giving a silent missing-first order or a loud, recoverable index-sort reopen failure.
Impact class
wrong error
Impact note
Applications that pass a missing value other than null, SortField.STRING_FIRST or SortField.STRING_LAST (for example a String such as "zzz" or a BytesRef) to new SortField(f, Type.STRING_VAL, reverse, x), new SortedSetSortField(f, reverse, selector, x) or KeywordField.newSortField(f, reverse, selector, x), with the default codec, IndexWriterConfig and searcher, -ea or -da, at any index size, on 10.4.0 through main: at search time the value is silently treated as STRING_FIRST, and when the SortedSetSortField/KeywordField sort is the index sort, the next IndexWriter opened with the identical config throws IllegalArgumentException 'cannot change previous indexSort'; nothing is lost, and the index opens again once the bogus value is removed from the config.
Summary
You build a string sort with a missing value that is not STRING_FIRST or STRING_LAST, for example new SortedSetSortField("tag", false, SortedSetSelector.Type.MIN, "zzz") or the same through KeywordField.newSortField, much as you would give a numeric sort field a substitute value. You expect an IllegalArgumentException when the sort field is built, as new SortField(f, Type.STRING, false, "zzz") throws and as the removed setMissingValue threw for these sorts too. Instead the value is accepted (getMissingValue() and toString() report it) but every comparator treats it as STRING_FIRST, so documents without a value silently sort first; and if the sort is the index sort, the segment is written without the value, so the next new IndexWriter with the very same config refuses the index with 'cannot change previous indexSort'.
Introduced
75108f430664 (apache/lucene#15483 'Deprecate SortField.setMissingValue()', by romseygeek, author date 2025-12-08, approved by benwtrent; branch_10x backport 202f206c2f97 of the same PR, first release 10.4.0) [read: git blame on main points validateField's 'if (type == Type.STRING)' block, the 4-argument SortedSetSortField constructor and the 4-argument KeywordField.newSortField to it; it added the missing-value constructors and kept the setter's sentinel check only for Type.STRING, while the setter checked 'type == Type.STRING || type == Type.STRING_VAL' and SortedSetSortField's setter override rejected anything but the two sentinels; exec: the repro does not compile on 10.3.2 (constructors absent) and fails on 10.4.0; not built at commit granularity]; on main f4f6d70c0c4c (#15486, 2025-12-09) then removed the rejecting setters, so nothing rejects such values there; the lost index-sort value comes from SortedSetSortField.serialize (de2bad903905, LUCENE-9330, 9.0.0), which writes 'no missing value' for anything but the sentinels and was latent until 10.4.0 (2025-12-08)
First affected version
10.4.0 [exec]
Affected versions
10.4.0 [exec -ea and -da], 10.5.0 and 10.5.1 [read: contain 202f206c2f97, same code as 10.4.0 and 10.5.2], 10.5.2 [exec -ea and -da], 10.6.0 RC1 4ae946e5a9b (released as 10.6.0 on 2026-10-29, the latest release) [exec -ea and -da], branch_10x 657014b3c579 [exec -da], main from 75108f430664 (2025-12-08) through 645d4fe14a20 [exec -ea and -da]; on 10.4.0-10.6.0 the deprecated setMissingValue still rejects the value [exec], so only code that moved to the constructors is affected; on main the constructors are the only way to set a missing value; not affected: every release up to 10.3.2 [exec on 9.12.3 and 10.3.2: the constructors do not exist (compile error) and setMissingValue rejects the value]; upstream main HEAD 2a32d42e5e85 (2026-10-29) has no later commit on SortField.java, SortedSetSortField.java or KeywordField.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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (the prior-report record, verdict new; searched 2026-10-29, read-only; the finder's searches, the independent reproduction's, the intended-behaviour check's and the adjudication's agree; refresh 2026-10-29 in an unpublished file): two scripted prior-report searches (the finder's 52 queries and the follow-up's 51 over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), manual GitHub searches (STRING_VAL, SortedSetSortField, missingValue and 'missing value', newSortField, the exact error messages, references to #15483, every issue and PR created since 2026-10-27), the changed files of the 196 open PRs updated since 2026-05-01 (#16857, #16276 and #15752 touch SortField.java or SortedSetSortField.java for other reasons) and the upstream commits on the fix files (none after 645d4fe14a20; main HEAD 2a32d42e5e85); Hibernate Search's GitHub and JIRA, lists.apache.org 2025-12-01..2026-10-30; related, not reports of this bug: #15483 (introduced the gap), #15486 (removed the rejecting setters), #15480 (open umbrella 'Make SortField immutable': constructors 'could also enhance type safety'), #15720 (closed duplicate deprecation PR), #15548 / #15727 (a different #15483 side effect in ToParentBlockJoinSortField, fixed by adding validation there), #16202 (BinarySortField, which validates), #8004 / #8003 / #8588 (history of the setter checks, SortField.equals and sorted-set index-sort serialization), HSEARCH-3254 and HSEARCH-2332 (downstream, 2018 and 2016: record that Lucene used to reject non-sentinel string missing values), elastic/elasticsearch#159050 and apache/solr#4651 (downstream moves to the constructors, passing sentinels); not fixed upstream
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle and a standalone runner, assertions on and off, and a print-only probe; the 10.3.2, 10.4.0, 10.5.2 and 10.6.0 release jars; the fix in a scratch worktree of 645d4fe14a20. Upstream main 716d6f216f52 (2026-10-29) was read, not run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:170-176 (at 645d4fe14a20) (4-argument constructor calls validateField), :309-324 (STRING_FIRST / STRING_LAST: 'Pass this as a missingValue constructor parameter to have missing string values sort first / last'), :337-349 (validateField: 'if (type == Type.STRING)' only), :451-461 (equals compares missingValue), :516-524 (STRING_VAL comparator reads only missingValue == STRING_LAST)
  • lucene/core/src/java/org/apache/lucene/search/SortedSetSortField.java:41-42 (at 645d4fe14a20) (class javadoc: missing values first or last via STRING_FIRST or STRING_LAST), :74-81 (4-argument constructor: super(field, Type.CUSTOM, reverse, missingValue), no check), :124-135 (serialize writes 1/2 for the sentinels, 0 otherwise), :173-176 and :189-191 (comparator and index sorter read only STRING_LAST)
  • lucene/core/src/java/org/apache/lucene/document/KeywordField.java:181-193 (at 645d4fe14a20) (newSortField(field, reverse, selector, Object missingValue) passes the value to SortedSetSortField; no @param missingValue)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1213-1239 (at 645d4fe14a20) (validateIndexSort / isCongruentSort: 'cannot change previous indexSort')
  • lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:488-493 (at 645d4fe14a20) (StringSorter: anything but STRING_LAST sorts missing first)
  • lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:72-98 (at 645d4fe14a20) and lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:178-197 (siblings that reject non-sentinel missing values); lucene/core/src/test/org/apache/lucene/search/TestSort.java:93-104 (testStringMissingValues asserts the Type.STRING rejection)
  • lucene/MIGRATE.md:258-278 (at 645d4fe14a20) ('SortField.setMissingValue() has been removed. Missing values should be configured in SortField constructor methods'; SortedSetSortField example with SortField.STRING_LAST)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:161-176 (at 645d4fe14a20) (4-argument constructor, no @param missingValue), :309-324 (STRING_FIRST, STRING_LAST), :337-349 (validateField), :451-461 (equals), :521-523 (STRING_VAL comparator)
  • lucene/core/src/java/org/apache/lucene/search/SortedSetSortField.java:41-42, (at 645d4fe14a20) :74-81, :95-106, :124-135, :175-176, :189-192
  • lucene/core/src/java/org/apache/lucene/document/KeywordField.java:181-193 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexSorter.java:512-519 (at 645d4fe14a20) (StringSorter.getDocComparator, flush; called from IndexingChain.java:292), :488-493 (StringSorter.getComparableProviders, merge; :140-150)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:935-947, (at 645d4fe14a20) :1213-1239, :3152-3158, :3228-3250; IndexWriterConfig.java:126-130 (config reuse)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/IntField.java:184 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:309-324, (at 645d4fe14a20) :337-349
  • lucene/core/src/java/org/apache/lucene/search/SortedSetSortField.java:41-42 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/search/TestSort.java:94-104 (at 645d4fe14a20)
  • a Lucene clone releases/lucene/10.5.2: lucene/core/src/java/org/apache/lucene/search/SortField.java:335-342 and lucene/core/src/java/org/apache/lucene/search/SortedSetSortField.java:172-184 (setMissingValue)
  • lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:72-73, (at 645d4fe14a20) :95-98
  • lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:178-198 (at 645d4fe14a20)
  • lucene/MIGRATE.md:258-278 (at 645d4fe14a20)

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

Repro files

About the diagrams

Made from the same example by its own print-only probe (three documents in both the search and the index-sort leg, so its segment reads c3). An independent check matched every drawn code line against 645d4fe14a20 and every on-screen value against the probe logs.

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.