IndexWriter refuses doc-values updates of fields "involved in the index sort", because the segment would fall out of its declared order. It takes those fields from each sort field's declared name only. A custom sort key may read other fields: BinarySortField's javadoc invites keys "from the stored bytes and/or other doc-values fields of the segment".
setIndexSort protects only sku, the price update is accepted while c stays last, the segment still declares its sort, the early-terminated top-1 returns a, a merge stores a, d, b, c, and the fix sketch refuses the update. Select the still to open it at full size. Generated with AI, checked against the code.- You sort the index with a
BinarySortFieldsubclass whose key is the numeric fieldprice, ties broken bysku, the field it is declared on; its javadoc allows keys read from other doc-values fields. You add a (price 5), b (7) and c (8): the segment stores them as a, b, c. - You set c's price to 1 with
updateNumericDocValue. You expect IndexWriter to refuse, as it refuses an update ofsku("cannot update docvalues field involved in the index sort"), or the order to become c, a, b. - Instead the update is accepted: the guard knows only the declared name
sku. The documents stay a (5), b (7), c (1), and the segment still declares the sort. - A top-1 search sorted by the index sort returns a, not c: it trusts the stored order and compares nothing after b, the first document that cannot compete. CheckIndex at level 2 reports
docID=1 sorts after docID=2; the default level 1 does not run that check. - Add d (6) and run
forceMerge(1): the merged segment stores a, d, b, c instead of c, a, d, b. It needs 3 documents and fails with assertions on or off. This form reproduces on 10.5.0-10.5.2 and main; the generic form (a customIndexSorterthat reads another field) on every release from 8.6.0 to 10.5.2 and main. 10.6.0 has the same code (read).
Applications with an index sort whose custom sort key reads a doc-values field other than the declared one (a BinarySortField subclass since 10.5.0, any custom getIndexSorter since 8.6.0) and that update that field in place. Default configurations are not affected. For those applications it is deterministic, needs 3 documents, and the misordered segment survives merges.
Mechanism, terms and numbers772 words
IndexWriter refuses in-place doc-values updates of a field "involved in the index sort", because the segment would then no longer be in its declared order. It decides which fields are involved from SortField#getField alone (IndexWriterConfig.java:512-513). Since LUCENE-9330 (8.6.0) a custom SortField#getIndexSorter can read any field, and since 10.5.0 BinarySortField's javadoc explicitly lets a subclass derive its key "from the stored bytes and/or other doc-values fields of the segment". An update of such a field is accepted. In the repro the key is (price, sku), declared on sku; a (5), b (7), c (8) are flushed in that order and updateNumericDocValue(id:c, price, 1) is accepted. The segment keeps the order a, b, c while still declaring the sort: CheckIndex at level 2 or higher reports segment has indexSort=<binary: "sku"> but docID=1 sorts after docID=2, a top-1 search sorted by the index sort returns a instead of c because it compares no document after the first non-competitive one, and after adding d (6) and forceMerge(1) the merged segment stores a, d, b, c instead of c, a, d, b. It is deterministic and needs 3 documents. This BinarySortField form fails on 10.5.0-10.5.2 and on main; the generic form (a custom IndexSorter that reads another field) fails on every release from 8.6.0 to 10.5.2 and on main. 10.6.0 has the same code (read). It needs an opt-in index sort with a custom sort key that reads another doc-values field, plus an in-place update of that field; default configurations are not affected.
- Index sort: with
IndexWriterConfig#setIndexSort, every flushed and merged segment stores its documents in that order and records the sort in its segment info. Searches and merges rely on the recorded order. - Custom sort key: since 8.6.0 a
SortFieldsupplies its ownIndexSorter, which gets aLeafReaderand so can read any field.BinarySortField(10.5.0) sorts by bytes; a subclass may overridegetSortKeyDocValuesto derive the key from other doc-values fields, and registers aSortFieldProviderso the sort can be read back on merge. - In-place doc-values update:
updateNumericDocValue,updateBinaryDocValue,updateSortedNumericDocValue,updateDocValues,tryUpdateDocValueandsoftUpdateDocument(s)change a document's doc values without reindexing it. The document keeps its position in the segment.
setIndexSortaccepts any sort field with anIndexSorter(IndexWriterConfig.java:506-510) and records the protected fields asSortField::getFieldonly (:512-513).getIndexSortFieldsis documented as "Returns the field names involved in the index sort" (LiveIndexWriterConfig.java:417-420).- Every update path rejects only those names, with "cannot update docvalues field involved in the index sort":
IndexWriter.java:1965(updateNumericDocValue),:2003(updateBinaryDocValue),:2041(updateSortedNumericDocValue) and:2098(buildDocValuesUpdate, shared byupdateDocValues,tryUpdateDocValueandsoftUpdateDocument(s)). - In the repro the key reads
pricebut is declared onsku(BinarySortField#getIndexSorterpassesgetSortKeyDocValuesto the sorter,BinarySortField.java:178-182). The update ofpriceis accepted, the documents do not move, and the segment still declares the sort. - Code that trusts the declared order goes wrong.
TopFieldCollectorchecks per segment whether the search sort is a prefix of the index sort and then, once the queue is full, compares no document after the first non-competitive one (TopFieldCollector.java:59,:93-104,:151-172; below the hit-count threshold, 1000 by default, it keeps counting hits without ranking them).MultiSortermerges segments on the rule "The documents for each incoming leaf reader must already be sorted by the same sort!" (MultiSorter.java:35-40). CheckIndex reports the first inversion (CheckIndex.java:1274-1282), at level 2 or higher only (:1114,:1146-1149). - The writer can find the fields a sorter reads, and does so elsewhere: when a doc-values field is first indexed into a segment,
IndexingChain#initializeFieldInfocallsvalidateIndexSortDVTypefor it (IndexingChain.java:1381-1385), which probes each sorter with an empty reader and type-checks whatever field it asks for (:1635-1727). The update guard does not use that.
Key = (price, sku), declared on sku. Add c (8), a (5), b (7): the segment stores a, b, c. updateNumericDocValue(id:c, price, 1) is accepted, so docs 0, 1, 2 are a (5), b (7), c (1). CheckIndex level 1: clean, the sort check does not run; level 2: segment has indexSort=<binary: "sku"> but docID=1 sorts after docID=2. Top-1 sorted by the index sort: a, after reading the keys of docs 0 and 1 only; the same sort with FIELD_DOC appended, which is not a prefix of the index sort and compares every document: c. Add d (6) and forceMerge(1): the merged segment stores a, d, b, c; the declared order is c, a, d, b.
Exposure and likelihood306 words
- Who. Applications that configure an index sort (opt-in) with their own sort key that reads a doc-values field other than the declared one, and that update that field in place: a
BinarySortFieldsubclass overridinggetSortKeyDocValues(10.5.0+,@lucene.experimental), or any customSortField#getIndexSorter(8.6.0+). The built-in sort fields read only their declared field, so this gap does not apply to them (on 10.5.x aBinarySortField's declared field was itself updatable, a separate gap fixed by #16565). Lucene's own tests derive keys only from the sort's own field (TestBinarySortField'sMinValueSortFieldjavadoc: "no companion field is needed"). - Trigger. Deterministic once both conditions hold: an accepted update of such a field breaks the segment whenever it changes the key order of documents within that segment. Three documents show all three symptoms; no concurrency; assertions on or off. A SORTED_NUMERIC companion (for example the minimum of a multi-valued numeric field) is updatable too, through
updateSortedNumericDocValue, which is on main and the 10.x branches and was released in 10.6.0 (#16581). - Effect. Silent when written. Sorted top-k searches that early-terminate on the index sort return wrong top hits,
searchAfterpages are wrong (bounded-exhaustive harness), and merges and lateraddIndexeswrite the disorder into new segments. CheckIndex at level 2 or higher reports the segment as broken; the default level 1 does not run the sort check. Documents are not lost, and filtered searches return only matching documents. A code fix does not repair segments that were already written, and a merge does not either (it carries the disorder forward); they have to be rewritten, for example by reindexing. - How rare. Registry likelihood "rare" (a judgement): an experimental extension point plus in-place updates of the field it reads; one public downstream subclass reads a companion field but shows no in-place updates of it, and nobody has reported this since 8.6.0.
IndexWriter accepts updateNumericDocValue of the field a BinarySortField subclass derives its index-sort key from, so the segment keeps its old order while still declaring the sort.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.index;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.lucene.document.BinaryDocValuesField;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.BinarySortField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
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.util.BytesRef;
import org.apache.lucene.util.BytesRefBuilder;
import org.apache.lucene.util.NumericUtils;
public class TestDerivedSortKeyDocValuesUpdate extends LuceneTestCase {
public static final class PriceThenSkuSortField extends BinarySortField {
public PriceThenSkuSortField() {
super("sku", false, null, Provider.NAME);
}
@Override
protected BinaryDocValues getSortKeyDocValues(LeafReader reader) throws IOException {
NumericDocValues price = DocValues.getNumeric(reader, "price");
BytesRefBuilder key = new BytesRefBuilder();
return new FilterBinaryDocValues(DocValues.getBinary(reader, "sku")) {
@Override
public BytesRef binaryValue() throws IOException {
while (price.docID() < docID()) {
price.nextDoc();
}
key.grow(Long.BYTES);
key.setLength(Long.BYTES);
NumericUtils.longToSortableBytes(price.longValue(), key.bytes(), 0);
key.append(in.binaryValue());
return key.get();
}
};
}
public static final class Provider extends SortFieldProvider {
static final String NAME = "PriceThenSku";
public Provider() {
super(NAME);
}
@Override
public SortField readSortField(DataInput in) {
return new PriceThenSkuSortField();
}
@Override
public void writeSortField(SortField sf, DataOutput out) {}
}
}
private static Document doc(String sku, long price) {
Document doc = new Document();
doc.add(new StringField("sku", sku, Field.Store.NO));
doc.add(new BinaryDocValuesField("sku", new BytesRef(sku)));
doc.add(new NumericDocValuesField("price", price));
return doc;
}
public void testUpdateOfFieldReadBySortKey() throws Exception {
Path spi = createTempDir();
Path services = spi.resolve("META-INF/services/" + SortFieldProvider.class.getName());
Files.createDirectories(services.getParent());
Files.writeString(services, PriceThenSkuSortField.Provider.class.getName());
try (URLClassLoader loader =
new URLClassLoader(new URL[] {spi.toUri().toURL()}, getClass().getClassLoader())) {
SortFieldProvider.reloadSortFieldProviders(loader);
}
try (Directory dir = new ByteBuffersDirectory()) {
Sort indexSort = new Sort(new PriceThenSkuSortField());
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setIndexSort(indexSort))) {
w.addDocument(doc("a", 5));
w.addDocument(doc("c", 8));
w.commit();
try {
w.updateNumericDocValue(new Term("sku", "c"), "price", 1);
} catch (IllegalArgumentException rejected) {
}
}
try (CheckIndex checker = new CheckIndex(dir)) {
checker.setLevel(CheckIndex.Level.MIN_LEVEL_FOR_INTEGRITY_CHECKS);
// expected: update rejected or segment kept sorted; actual: "docID=0 sorts after docID=1"
assertNull(checker.checkIndex().segmentInfos.get(0).indexSortStatus.error);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-759/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.index.TestDerivedSortKeyDocValuesUpdate
- Expected vs actual
- Expected: the update is rejected (as it is for the sort's own field) or the segment stays sorted; actual: CheckIndex reports
segment has indexSort=<binary: "sku"> but docID=0 sorts after docID=1.
More files, environment, full output44 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected null, but was:<org.apache.lucene.index.CheckIndex$CheckIndexException: segment has indexSort=<binary: "sku"> but docID=0 sorts after docID=1>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotNull(Assert.java:756)
org.junit.Assert.assertNull(Assert.java:738)
org.junit.Assert.assertNull(Assert.java:748)
org.apache.lucene.index.TestDerivedSortKeyDocValuesUpdate.testUpdateOfFieldReadBySortKey(TestDerivedSortKeyDocValuesUpdate.java:116)setIndexSortpromises the sort "for all (flushed and merged) segments", andgetIndexSortFields"Returns the field names involved in the index sort".BinarySortField's javadoc invites keys read from other doc-values fields; no javadoc says those fields must not be updated.- When indexing doc values, the writer already probes each sorter for the fields it reads; only the update guard ignores them.
The full argument290 words
- Contracts.
setIndexSort: "Set the Sort order to use for all (flushed and merged) segments" (IndexWriterConfig.java:504).getIndexSortFields: "Returns the field names involved in the index sort" (LiveIndexWriterConfig.java:417); a field the key is computed from is involved. The guard's own message says "involved in the index sort". - The guard's intent. LUCENE-7581 (#8632, 6.4.0) added it; its CHANGES entry reads "Lucene now prevents updating a doc values field that is used in the index sort, since this would lead to corruption" (
lucene/CHANGES.txt:8683-8685), and in that issue a committer asked to "prevent changing any field involved in the index sort". In 2016 a sort could read only its own field, so a name check was complete. #16565 (10.6.0) extended it toupdateBinaryDocValue: "Updating a sort field's doc values would make the persisted sort order inconsistent with the values, so it should be rejected up front". - The documented pattern.
BinarySortField's class javadoc (BinarySortField.java:41-45) lets a subclass derive the key from "other doc-values fields of the segment";getSortKeyDocValues(:149-158) restricts only the read pattern and the value lifetime. NeitherBinarySortField,SortField#getIndexSorter,IndexSorter,SortFieldProvider,setIndexSortnor the update methods say that such fields must not be updated. - The inconsistency.
IndexingChain#validateIndexSortDVType(IndexingChain.java:1635-1727) treats a field the sorter reads as part of the index sort: a devil's-advocate probe saw a BINARYpricerejected under a sort that reads NUMERICprice("expected field [price] to be NUMERIC"). The update guard does not. CheckIndex, Lucene's own checker, calls the result an error. - Tests.
TestIndexSorting#testBadDVUpdateasserts the rejection for the declared sort field; no upstream test updates a field that a sorter reads, and none asserts that such an update is allowed.
The devil's-advocate check165 words
The devil's-advocate verifier and the cross-model reviews built the strongest case for "intended": BinarySortField and SortField#getIndexSorter are expert @lucene.experimental hooks (the IndexSorter interface and SortFieldProvider carry no such tag), a SortField exposes one field name, and the writer cannot know what an opaque sorter reads, so keeping companion fields stable is the extension author's job.
Why it fails: no javadoc states that rule; the writer can and does discover the fields a sorter reads (validateIndexSortDVType); the guard's stated intent covers "any field involved in the index sort"; and the outcome is a segment that Lucene's own CheckIndex rejects and that merges propagate, not a documented limitation. Every verifier rated the case at most moderate. Verdict: a defect. What remains is a point about the fix: upstream may prefer a documented rule, or an API for a sort field to declare the fields it reads, over probing; so state the expectation as "reject the update, or document that such fields must not be updated".
Recommended Protect every field the sorters read (validated)
In IndexWriterConfig#setIndexSort, also protect every doc-values field that each sort field's IndexSorter asks an empty probe reader for, like the probe used when indexing doc values (+61/-4, one file). The repro passes with assertions on and off, and the full lucene/core suite passes. Best effort: a sorter that picks its fields from segment state escapes the probe.
for (SortField sortField : sort.getSort()) {
fields.add(sortField.getField());
try {
sortField.getIndexSorter().getDocComparator(probe, 0);
} catch (IOException | RuntimeException _) {
// best effort: if a sorter fails here, fields it would read later are not recorded
}
}
return Collections.unmodifiableSet(fields);All options and trade-offs4 options · 682 words
One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF). The other options were not run. Which one, or another design, is the maintainers' call; option D could cover the sibling gaps in one change.
setIndexSort builds indexSortFields from each sort field's name plus every doc-values field its IndexSorter requests from an empty recording DocValuesLeafReader, a probe modelled on the one in validateIndexSortDVType: both call getDocComparator(reader, 0) on an empty doc-values reader, but that one type-checks a single field and throws from getFieldInfos(), while this one records every name requested and returns FieldInfos.EMPTY. Patch:, +61/-4 in IndexWriterConfig.java; updateNumericDocValue and updateDocValues of price are rejected, and the test checks the guard's message for updateNumericDocValue; an unrelated field stays updatable). The finder's earlier sketch (in the hunt folder) is the same logic.
for (SortField sortField : sort.getSort()) {
fields.add(sortField.getField());
try {
sortField.getIndexSorter().getDocComparator(probe, 0);
} catch (IOException | RuntimeException _) {
// best effort: if a sorter fails here, fields it would read later are not recorded
}
}
return Collections.unmodifiableSet(fields);Trade-offs
- Best effort: a sorter that chooses its fields from segment state (
FieldInfos,maxDoc, values) escapes the probe, and so does any field a sorter would request after it throws on the empty reader (fields requested before the throw stay recorded); a point the cross-model reviewer raised. - Cost: each sorter runs once per
setIndexSort, withmaxDoc0. - The probe calls
getIndexSorter()a second time and swallows anyRuntimeExceptionit throws, which matters only for a stateful or side-effecting sorter. getIndexSortFields()now returns an unmodifiable set instead of theCollectors.toSet()result (its mutability was never documented).getIndexSortFields()returns more names, which matches its javadoc; a downstream caller of it (one exists in Solr's atomic-update code) would then protect those fields too.- The probe could be shared with
validateIndexSortDVTypeinstead of duplicated.
What the checks validated
- Stock: the repro class fails 3 of 4 with assertions on and off; the control passes. The fifth (rejection) test fails on stock too:
expected:<[price, sku]> but was:<[sku]>. - With the fix: the 5 tests pass with assertions on and with assertions off.
- With the fix:
TestIndexSorting,TestBinarySortField,TestIndexWriterConfig,TestNumericDocValuesUpdates,TestBinaryDocValuesUpdates,TestMixedDocValuesUpdatesandTestSortedNumericDocValuesUpdatespass (188 tests, 2 skipped, 0 failures). - With the fix: the full
lucene/coretest suite passes with assertions on (8,894 tests, 322 skipped, BUILD SUCCESSFUL; the JUnit XML of the fact check's re-run counts 749 classes, 0 failures and 0 errors, including the 5-test class);:lucene:core:ecjLintand:lucene:core:checkGoogleJavaFormatpass. - Earlier, with the finder's sketch shadowed: the finder's, the independent reproducer's and the rootcause step's tests and the generic probe pass;
TestIndexSortingandTestBinarySortFieldpass (87 tests). - After the fact checks the catch block's comment was reworded (no code change): on a fresh scratch worktree both diffs apply, the 5 tests pass with assertions on and off, and ECJ lint and the format check pass. The full suite was not re-run for that change.
For example a SortField method that returns Set.of(getField()) by default and that BinarySortField subclasses override; setIndexSort takes the union.
Trade-offs
Exact and cheap, with no probing. But a subclass that forgets to declare its companion field silently keeps today's behaviour, and it adds API to experimental classes.
State in BinarySortField, getSortKeyDocValues and SortField#getIndexSorter that fields a custom sort key reads must not be updated in place.
Trade-offs
No code change, but the silent misordering stays for anyone who misses the sentence, and CheckIndex at the default level does not catch it.
Combine the first or second option with open PR #16810's ensureNotIndexSortField (the sort taken from existing segments when the writer config has none) and with the reserved parent field (hunt row LFV-095), so that one check covers all three.
Trade-offs
Larger change across IndexWriter and IndexWriterConfig; #16810 is open and stale.
One public downstream BinarySortField subclass reads a companion doc-values field (elastic/elasticsearch#152504); nothing shows that it updates that field in place, so no downstream impact is claimed.
A bounded-exhaustive model-based test ran every index-sort operation script of up to 4 steps (adds, deletes, updates, flushes, merges, addIndexes, reopen) in 26 configurations, 1,975,300 scripts, against an independent reference order. Every failure came from updating the field the key reads. The lead came from the hunt plan, so the test confirmed a suspected gap.
The method in full193 words
Method: bounded-exhaustive model-based testing on the real jar (semi-formal tier).
- Operations: add, delete first or last, update of the sort field, update of the field the derived key reads, flush,
forceMerge(1),addIndexesof a sorted reader,addIndexes(SortingCodecReader.wrap(unsorted)), and commit plus reopen. Every script of length 4 or less, in 26 configurations (default and derived keys, missing first or last, reverse, two multi-field sorts, search only). - Oracle after every step: an independent reference order, checked against each segment's physical order, CheckIndex's sort check, top-k with and without the index-sort shortcut,
searchAfterpages and the comparator axioms. - Result: 1,975,300 scripts and 7,769,228 steps with assertions off; every failure, and only failures, in scripts that update the companion field. The shortest counterexample was minimised into a JUnit.
- The hypothesis was a planning lead (an unexecuted lead from an earlier unit), so the harness confirmed and pinned down a suspected gap; it was not a blind find. Found 2026-10-27 on main
a9779ef3c193; then checked by an independent reproducer, a devil's-advocate verifier, reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a follow-up (history, prior reports, security), the registration check and an audit.
No prior report found (2026-10-27) in GitHub issues, PRs and commits, the mailing lists, the JIRA archive, Elasticsearch, OpenSearch and Solr, or the changed files of all 408 open PRs. Closest: #8632 (the guard's origin), open #16809 and #16810 (a sibling gap; #16810 does not fix this) and #16565.
Searches3 searches
Verdict new: 21 hits read, none the same bug. Searches cannot prove absence.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream repositories; the dev@, java-user@, issues@ and commits@ lists; the JIRA archive | 2026-10-27 | three runs of the hunt's prior-report tool, 45 queries each (identifiers and messages; the generic custom-IndexSorter root; history, downstream use and in-place updates), plus the finder's 45-query run and the devil's-advocate verifier's 43-query run | no report; context only: #16202, #10370, LUCENE-7581 |
| GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr), lists, web | 2026-10-27 | about 40 manual GitHub searches (identifiers, error messages, phrasings such as index sort with doc values update, in-place, derived, sort key, custom sort), 13 list searches, 3 web searches; threads read in full: #16202, #16565, #16809, #16810, #16581, #8632, #10370, lucene-solr#1440, elastic/elasticsearch#152504 | no report; #16809/#16810 are a sibling gap (no index sort in the writer config), and #16810 does not fix this (its IndexWriter.java applied, the repro still fails) |
| Open apache/lucene PRs | 2026-10-27 | changed files of all 408 open PRs; the 29 that touch the guard or sort files grepped for the guard identifiers | only #16810 changes the guard |
Introduced in 8.6.0 by LUCENE-9330 (2020), which made index sorting pluggable but kept the update guard keyed on the declared field. BinarySortField (10.5.0, #16202) first documented keys read from other fields. Executed: fails on all 49 releases from 8.6.0 to 10.5.2 and on main; 8.5.x cannot express it.
Versions tested5 rows
Introduced by LUCENE-9330 (#10370), apache/lucene-solr#1440 "Make SortFields responsible for index sorting and serialization" (@romseygeek, approved by @jpountz), merged 2020-05-22 as ad0cefb8ec2 on branch_8x (first released in 8.6.0) and de2bad90390 on master (first released in 9.0.0). It added SortField#getIndexSorter, IndexSorter and SortFieldProvider, and switched validateIndexSortDVType to probing the sorter, but left indexSortFields and the guards keyed on getField. The guard itself is LUCENE-7581 (#8632; 4efbde4e762 on master, 928fa91c894 on branch_6x, first released in 6.4.0), complete when written. #16202 (@jimczi, e6f12f31d08 / 5a3ec9ad9d8, 10.5.0) added BinarySortField, the first javadoc that sanctions the pattern; #16581 (on main and the 10.x branches, released in 10.6.0) made SORTED_NUMERIC companions updatable.
Upstream main f7bcc0abd81 and branch_10_6 still build indexSortFields from SortField::getField (read, not run).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | compact JUnit 3 of 4 fail through gradle, assertions on and off; control passes |
| 10.5.0, 10.5.1, 10.5.2 | affected | the finder's BinarySortField test 3 of 3 fail (its control too: #16565 is not in 10.5.x) |
| 8.6.0 to 10.4.0 (46 releases) | generic form | the generic probe: the price update is accepted and the segment keeps b (5), a (7), c (1) under its sort; BinarySortField does not exist before 10.5.0 |
| 8.5.0, 8.5.1, 8.5.2 | not affected | cannot be expressed: no IndexSorter; a custom index sort is rejected and the built-in sort field is guarded |
fix (scratch worktree at a9779ef3c193) | not affected | the price update is rejected; 5 of 5 tests pass, assertions on and off; full lucene/core suite passes |
An independent reproducer and a devil's-advocate verifier reproduced it with their own tests; reviews by a model from another family (OpenAI GPT-5.6) read the code, could not run Java, and agreed on the root cause; an adjudication confirmed it with high confidence, and the registration check and an audit re-ran the tests.
Checks and verdicts306 words
Each executing check wrote or re-ran its own JUnit on stock main; every control passed.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own public-API test written from the claim only: 5 of 7 fail on main with assertions on and off (key order, CheckIndex via two update methods, the early-terminated top-1, the merged order adbc); 6 of 7 on 10.5.0 and 10.5.2; 7 of 7 pass with the fix sketch. |
| devil's advocate | real; no intended or misuse argument holds | Built the extension-author-responsibility case and rejected it; found the validateIndexSortDVType inconsistency; ran the generic form on main, 10.5.2 and 9.12.3. |
| cross-model reviews (intended, refute, rootcause) | real (medium), formally not real (low, procedural), real (medium) | An independent reviewer from another model family (OpenAI GPT-5.6) reviewed by reading only, and read an earlier main, ea57360912ed, where the code this bug involves is the same; the refute lens said its verdict was procedural, and all agreed on the root cause. For some of the reviews, a separate run re-ran the finder's test (2 of 3 fail); a separate test covered updateDocValues and tryUpdateDocValue. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test with a new seed: 5 of 7 fail with assertions on and off. |
| Registration check and audit | reproduces; keep | Re-ran the finder's test (2 of 3 fail, assertions on and off) and the independent reproduction's test (5 of 7 fail) with new seeds; 3 of 3 pass with the fix sketch. |
| this doc's gradle runs | reproduces; fix validated | Compact JUnit through gradle on stock main (3 of 4 fail) and with the fix (5 of 5 pass), assertions on and off; with the fix the closest suites and the full lucene/core suite (8,894 tests) pass, and ECJ lint and the format check pass. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 418 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,504 words
- Registry title
- IndexWriter accepts doc-values updates of a field that a custom index-sort key reads (such as a BinarySortField subclass that derives its key from another doc-values field), so the segment keeps its old order while still declaring the index sort: CheckIndex rejects it, sorted searches that terminate early return wrong top hits, and merges carry the disorder forward
- Area
- index / IndexWriter doc-values updates with index sorting (BinarySortField, custom IndexSorter)
- Priority
- Low, rank 322: Needs an experimental custom index-sort key that reads a companion doc-values field plus in-place updates of that field.
- Impact class
- data corruption
- Impact note
- Hits only applications that opt into an index sort whose SortField reads a doc-values field other than its declared one (a BinarySortField subclass overriding getSortKeyDocValues, which the class javadoc allows since 10.5.0, or any custom SortField#getIndexSorter since 8.6.0) and that update that other field in place (updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue, updateDocValues, tryUpdateDocValue or softUpdateDocument(s)): the update is accepted, the segment keeps its old physical order under its declared sort, CheckIndex at level 2 or higher rejects it (the default level 1 does not run the sort check), sorted top-k searches that terminate early on the index sort return wrong top hits, and later merges write the disorder into new segments; deterministic with -ea and -da, tiny scale (3 documents), released 8.6.0-10.6.0 (generic form) and 10.5.0-10.6.0 (BinarySortField form), 10.6.0 by reading, and segments already written stay misordered after a code fix.
- Summary
- You configure an index sort with a BinarySortField subclass whose key is derived from another doc-values field (sort by the numeric field price, ties broken by the binary field sku, declared on sku), index a(5), b(7), c(8), and then change c's price to 1 with updateNumericDocValue; you expect the writer either to reject the update, as it rejects an update of sku itself ('cannot update docvalues field involved in the index sort'), or to keep the segment sorted. Instead the update is accepted, the segment stays in the order a, b, c while still declaring the sort, CheckIndex reports 'segment has indexSort=<binary: "sku"> but docID=1 sorts after docID=2', and a top-1 search sorted by the index sort returns a instead of c because it stops at the first non-competitive document. The cause is that IndexWriterConfig#setIndexSort protects only the SortField#getField names, while a custom index sort may read any field.
- Introduced
- ad0cefb8ec2 (branch_8x, first released in 8.6.0) and de2bad90390 (master, first released in 9.0.0): LUCENE-9330 (now #10370), apache/lucene-solr#1440 'Make SortFields responsible for index sorting and serialization' by romseygeek, approved by jpountz [read: git blame, git log -S and git tag --contains in the history clone; executed API boundary: the generic probe does not compile on 8.5.0-8.5.2 and fails on 8.6.0]. It made index sorting pluggable (SortField#getIndexSorter, IndexSorter value providers that read any field from a LeafReader, SPI SortFieldProvider) and switched the index-sort doc-values type check to probing the sorter, but left indexSortFields and the update guards keyed on SortField#getField. The guard itself comes from LUCENE-7581 (#8632, 4efbde4e762, 6.4.0), complete when written; apache/lucene#16202 (e6f12f31d08 on main, 5a3ec9ad9d8 on branch_10x, 10.5.0, by jimczi) added BinarySortField, the first javadoc that sanctions a key derived from other doc-values fields; #16581 (169cbffd12f; backport eaa3a7a9edb, released in 10.6.0) made SORTED_NUMERIC companion fields updatable too (2020-05-22 (LUCENE-9330 merged); 2026-06-05 for the BinarySortField form (#16202))
- First affected version
- 8.6.0 [exec: the generic custom-IndexSorter probe fails; 8.5.0-8.5.2 compile_error because IndexSorter, SortFieldProvider and SortField#getIndexSorter are absent, and an 8.5.2 control shows custom index sorts rejected and the built-in sort field guarded]; BinarySortField form 10.5.0 [exec; 10.4.0 compile_error, class absent]
- Affected versions
- [exec] generic form (a Type.CUSTOM SortField declaring sku whose IndexSorter reads NUMERIC price): fails on all 49 releases 8.6.0 to 10.5.2 and on main a9779ef3c193 with -ea, also with -da on 8.6.0, 9.12.3, 10.5.2 and main (follow-up history sweep); the intended-behaviour check's own JUnit probe fails on 9.12.3, 10.5.2 and main. BinarySortField form (the finder's JUnit): fails on 10.5.0, 10.5.1, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit on 10.5.0, 10.5.2 and main. SORTED_NUMERIC companion via updateSortedNumericDocValue: main (bounded-exhaustive harness) and 10.6.0 by reading (#16581 shipped in 10.6.0 as backport eaa3a7a9edb). [read] branch_10_6 (10.6.0, released 2026-10-29) has the same code; upstream main f7bcc0abd81 is unchanged (no commit after a9779ef3c193 touches IndexWriter.java, IndexWriterConfig.java, BinarySortField.java or IndexSorter.java). Not affected: 8.5.2 and older (no accepted index sort can read a field other than SortField#getField). All runs with JDK 25.0.3 on aarch64
- 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, randomized/MBT
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record, verdict new): three scripted prior-report searches of 45 queries each (16 GitHub issue/PR, 3 commit, 2 downstream-repository, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive; covering identifiers and messages, the generic custom-IndexSorter root, and history plus downstream use), about 40 manual GitHub GraphQL searches over apache/lucene, Elasticsearch, OpenSearch and Solr, 13 direct mailing-list searches, 3 web searches, the file lists of all 408 open apache/lucene PRs (29 touch the guard or sort files; only #16810 changes the guard) and the git history of the guard; plus the finder's 45-query run, the independent reproduction's spot checks and the intended-behaviour check's 43-query run; 21 hits read, none the same bug. Closest items, not reports: #8632 (LUCENE-7581, 2016, the ancestor that added the name-keyed guard; its stated intent was to 'prevent changing any field involved in the index sort'); open issue #16809 and open, stale PR #16810 by harshvardhanrp (updates of the declared sort field when the writer config has no index sort; #16810 still keys on SortField#getField and the repro still fails with its IndexWriter.java applied [exec]); #16565 (merged, 10.6.0: the guard for updateBinaryDocValue of the declared sort field); #16202 (added BinarySortField; no discussion of updates); #10370 and lucene-solr#1440 (LUCENE-9330, the introducing change; updates not discussed); elastic/elasticsearch#152504 (a downstream BinarySortField subclass that reads a companion doc-values field; its review is about performance only, and nothing shows in-place updates of that field). Not fixed upstream: upstream main f7bcc0abd81 still builds indexSortFields from SortField::getField
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): a compact public-API JUnit through gradle in a scratch worktree, stock and with the fix, with assertions on and off, plus the closest test suites and the fulllucene/coresuite with the fix (2026-10-27); repro runs by the finder, an independent reproducer, a devil's-advocate verifier, the adjudication, the registration check, an audit and the diagram probe; the follow-up's version sweep (a generic probe on all 49 releases from 8.6.0 to 10.5.2 and on main, 8.5.0-8.5.2 as the API boundary; the BinarySortField test on 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main). OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504-515 (at a9779ef3c193) (setIndexSort javadoc 'Set the Sort order to use for all (flushed and merged) segments'; :506-510 accept any SortField with a non-null getIndexSorter(); :512-513 indexSortFields = SortField::getField only)lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:417-420 (at a9779ef3c193) (getIndexSortFields: 'Returns the field names involved in the index sort')lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1965 (at a9779ef3c193) (updateNumericDocValue), :2003 (updateBinaryDocValue), :2041 (updateSortedNumericDocValue), :2098 (buildDocValuesUpdate, used by updateDocValues, tryUpdateDocValue and softUpdateDocument(s)): each rejects only names in config.getIndexSortFields(), message 'cannot update docvalues field involved in the index sort'lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:41-45 (at a9779ef3c193) (class javadoc: a subclass may derive the key 'from the stored bytes and/or other doc-values fields of the segment' and must register a SortFieldProvider), :50 (@lucene.experimental), :148-158 (getSortKeyDocValues javadoc restricts only the read pattern and value lifetime)lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1384 (at a9779ef3c193) and :1635ff (validateIndexSortDVType probes each IndexSorter with a doc-values reader and type-checks whichever field it reads)lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114 (at a9779ef3c193) and :1146-1149 (testSort runs only at level >= MIN_LEVEL_FOR_INTEGRITY_CHECKS), :4741-4754 (MIN_VALUE = DEFAULT_VALUE = 1, MIN_LEVEL_FOR_INTEGRITY_CHECKS = 2); lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:303 (TestUtil.checkIndex uses level 3)lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:59 (at a9779ef3c193) and :151-172 (a search sorted by a prefix of the index sort early-terminates per segment)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504-515lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:417-420lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1664, 1721, 1938, 1965, 2003, 2041, 2070, 2081, 2098lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:41-45, 50, 149-158, 178-182lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:59, 93-104, 151-172lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 733 (TOTAL_HITS_THRESHOLD = 1000)lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:35-40lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114, 1146-1149, 1274-1282, 4742-4754lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1381-1385, 1635-1727
- Who is affected
lucene/core/src/test/org/apache/lucene/search/TestBinarySortField.java:1130-1140 (MinValueSortField javadoc)lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114, 4742-4754
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:417lucene/CHANGES.txt:8683-8685 (LUCENE-7581), :724 (GITHUB#16565), :928 (GITHUB#16202)lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:41-45, 149-158lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1635-1727lucene/core/src/test/org/apache/lucene/index/TestIndexSorting.java:1988 (testBadDVUpdate)
- History
a Lucene clone: eaa3a7a9edb (#16581 backport, contained in origin/branch_10x and origin/branch_10_6; branch_10_6 IndexWriter.java:2049 updateSortedNumericDocValue; absent from 10.5.2); lucene/CHANGES.txt:513 (GITHUB#16580 under 10.6.0)
- TestDerivedSortKeyDocValuesUpdate.java (JUnit test):
repro/LFV-759/src/org/apache/lucene/index/TestDerivedSortKeyDocValuesUpdate.java(120 lines)
Made in parallel with this doc from a probe run on main a9779ef3c193 (stock and with the fix sketch); the values on screen come from the probe's trace.
assets/LFV-759/bug.png(418 KB)
