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.
"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.- 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. - You expect an
IllegalArgumentException. String sorts support onlySTRING_FIRSTandSTRING_LAST:new SortField("tag", Type.STRING, false, "zzz")throws, and so did thesetMissingValuethese constructors replaced. - Instead the value is accepted and ignored.
toString()showsmissingValue=zzz, but the order is(none), apple, banana, as withSTRING_FIRST;STRING_LASTgivesapple, banana, (none). - Make it the index sort (the probe indexes "apple" and the document without a tag). The first
IndexWriterwrites the segment with its sort stored as<sortedset: "tag"> selector=MIN, without the value. The nextIndexWriter, given a freshIndexWriterConfigwith the same sort, throwscannot change previous indexSort. In the probe nothing was lost: with the value removed from the sort, the index opens.
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.
- Missing value: the fourth argument of
new SortField(field, type, reverse, missingValue),new SortedSetSortField(field, reverse, selector, missingValue)andKeywordField.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_FIRSTorSortField.STRING_LAST(SortField.java:309-324).nullmeans the default, which sorts likeSTRING_FIRST. STRING_VAL: aSortFieldtype that compares theBytesRefvalues of a binary doc-values field.SortedSetSortFieldsorts a sorted-set field, which is whatKeywordField.newSortFieldreturns.- 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 newIndexWriterchecks that its config's sort matches every existing segment's.
SortField.validateFieldrejects a non-marker value onlyif (type == Type.STRING)(SortField.java:343-348). TheSortedSetSortFieldconstructor passesType.CUSTOMtosuperand checks nothing itself (SortedSetSortField.java:74-81);KeywordField.newSortFieldpasses the value on (KeywordField.java:188-193).- 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 likeSTRING_FIRST. SortedSetSortField.serializewrites 1 forSTRING_FIRST, 2 forSTRING_LASTand 0 otherwise (SortedSetSortField.java:124-135), so the stored sort has no missing value.SortField.equalscompares the missing value (SortField.java:451-461), soIndexWriter.validateIndexSortfinds the config's sort unequal to the stored one and throws (IndexWriter.java:1213-1239). Each writer needs its ownIndexWriterConfigwith the same sort; reusing the first writer's config fails earlier, withIllegalStateException: do not share IndexWriterConfig instances across IndexWriters(IndexWriter.java:935-947). Within the first writer session nothing fails: two commits and aforceMerge(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'smissingValueis "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,
IndexWriterConfigand 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, throwscannot change previous indexSort. In the executed probes no documents were lost,CheckIndexwas 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.
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.
/*
* 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"));
}
}
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, asnew 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.
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.
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.
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();
}
}
| lucene | main 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) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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
# 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- The same check exists one
ifaway:Type.STRINGrejects"zzz", added by the same PR with a test (TestSort#testStringMissingValues). - The removed
setMissingValuerejected it forSTRING,STRING_VALandSortedSetSortField, andBinarySortFieldandToParentBlockJoinSortFieldreject it today. IndexWriteraccepts 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.STRINGtovalidateFieldwithTestSort#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.setMissingValuethrew "For STRING type, missing value must be either STRING_FIRST or STRING_LAST" whentype == 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);ToParentBlockJoinSortFieldrejects 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
SortedSetSortFieldexample passingSortField.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.
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.
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,BinarySortFieldandToParentBlockJoinSortField. - Code on 10.4 to 10.6 that passes such a value gets an
IllegalArgumentExceptionafter 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
nullas the missing value (run). Keeping the old value, or swapping inSTRING_FIRST, is refused:STRING_FIRSTsorts 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,TestSegmentReorderingand 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.
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.
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.
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.
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.
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,BinarySortFieldandToParentBlockJoinSortField, each withnull,STRING_FIRST,STRING_LAST,"zzz","", aBytesRefand anInteger, 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.newSortFieldwas not a separate row; it calls theSortedSetSortFieldconstructor (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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), JIRA archive, Elasticsearch, OpenSearch, Solr, the web | 2026-10-29 | two 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-27 | no report of this defect |
| Open PRs by file | 2026-10-29 | changed 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 JIRA | 2026-10-29 | issues@, 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 refresh | 2026-10-30 | all 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 files | nothing related; the only new commit on the fix files is #16857, which does not touch the bug's lines |
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:
setMissingValuestarts rejecting non-marker string values (LUCENE-5399, LUCENE-5835 forSTRING_VAL, LUCENE-5454 / LUCENE-5666 forSortedSetSortField). - 9.0.0: LUCENE-9330 (
de2bad903905) givesSortedSetSortField.serializeits marker-only encoding; harmless while the setter enforced the markers. - 2025-12-08: #15483 "Deprecate SortField.setMissingValue()" (romseygeek, approved by benwtrent),
75108f430664on main, backport202f206c2f97; 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
716d6f216f52keeps the same checks, predicates andserialize(read; #16857 changed nearby comparator code); 10.6.0 is released with the bug.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the test fails 2 of 2, assertions on and off (gradle and standalone) |
10.6.0 (latest release, the RC1 revision 4ae946e5a9b9) | affected | 2 of 2 fail, assertions on and off (release jars) |
| 10.5.2, 10.4.0 | affected | 2 of 2 fail, assertions off (release jars) |
| 10.5.0, 10.5.1 | read | contain 202f206c2f97, same code |
| 10.3.2, 9.12.3 | API absent | the test does not compile; setMissingValue("zzz") is rejected |
upstream main 716d6f216f52, branch_11x | read | the same checks, predicates and serialize; #16857 changed nearby comparator code and shifted line numbers (not built or run) |
with fix A (main 645d4fe14a20) | not affected | the test passes, assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | candidate | 70-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 moderate | Own 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 | real | Own 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 refute | Own test fails 3 of 4 (the STRING_LAST control passes); agrees on the root cause and the fix. |
| Devil's advocate | real, Low | No intended, documented, misuse or unreachable argument survives. |
| adjudication | confirmed, high; wrong error, severity Low | Re-ran the independent reproduction's test on main (-da): 7 run, 4 failed, controls pass. |
| fact checks (2026-10-30) | real; wording fixes applied | A 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 above | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 537 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,306 words
- 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 of645d4fe14a20. Upstream main716d6f216f52(2026-10-29) was read, not run.
- 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-192lucene/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-349lucene/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-98lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinSortField.java:178-198 (at 645d4fe14a20)lucene/MIGRATE.md:258-278 (at 645d4fe14a20)
- TestStringSortMissingValue.java (lucene/core, package org.apache.lucene.search):
repro/LFV-1037/TestStringSortMissingValue.java(35 lines) - StepsProbe.java (print-only probe behind the steps; not the repro):
repro/LFV-1037/StepsProbe.java(101 lines)
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.
assets/LFV-1037/bug.png(537 KB)
