All findings · Lucene finding · LFV-759 · binarysortfield-derived-key-companion-update-accepted

IndexWriter accepts doc-values updates of a field that a custom index sort reads, so a segment can fall out of its declared order

IndexWriter refuses in-place doc-values updates of the index-sort field, but not of another field a custom sort key reads (here price, behind a BinarySortField subclass). The segment then keeps its old order under the declared sort: CheckIndex rejects it, sorted searches return a wrong top hit, merges carry it on.

Summary

Priority
Low, rank 322Needs an experimental custom index-sort key that reads a companion doc-values field plus in-place updates of that field.
Impact
data corruption
Since
8.6.0 (LUCENE-9330, 2020); the BinarySortField form since 10.5.0introduced 2020-05-22 · about 6 years
Affected
8.6.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
a bounded-exhaustive model-based test of index-sort operations

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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

Diagram of the bug: an index sorted by a key read from price, ties broken by sku, declared on sku. setIndexSort protects only sku (IndexWriterConfig.java:512-513). Updating sku is rejected; updating c's price from 8 to 1 is accepted, and the segment still stores a (5), b (7), c (1) under the declared sort. CheckIndex at level 2 reports docID=1 sorts after docID=2. A top-1 search sorted by the index sort returns a instead of c. Merging with d (6) stores a, d, b, c instead of c, a, d, b. With the fix sketch, which protects every field the sorter reads, the price update is refused.
Step by step through the executed repro: 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.
  1. You sort the index with a BinarySortField subclass whose key is the numeric field price, ties broken by sku, 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.
  2. You set c's price to 1 with updateNumericDocValue. You expect IndexWriter to refuse, as it refuses an update of sku ("cannot update docvalues field involved in the index sort"), or the order to become c, a, b.
  3. 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.
  4. 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.
  5. 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 custom IndexSorter that reads another field) on every release from 8.6.0 to 10.5.2 and main. 10.6.0 has the same code (read).

Who is affected

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.

Terms

  • 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 SortField supplies its own IndexSorter, which gets a LeafReader and so can read any field. BinarySortField (10.5.0) sorts by bytes; a subclass may override getSortKeyDocValues to derive the key from other doc-values fields, and registers a SortFieldProvider so the sort can be read back on merge.
  • In-place doc-values update: updateNumericDocValue, updateBinaryDocValue, updateSortedNumericDocValue, updateDocValues, tryUpdateDocValue and softUpdateDocument(s) change a document's doc values without reindexing it. The document keeps its position in the segment.

The bug

  1. setIndexSort accepts any sort field with an IndexSorter (IndexWriterConfig.java:506-510) and records the protected fields as SortField::getField only (:512-513). getIndexSortFields is documented as "Returns the field names involved in the index sort" (LiveIndexWriterConfig.java:417-420).
  2. 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 by updateDocValues, tryUpdateDocValue and softUpdateDocument(s)).
  3. In the repro the key reads price but is declared on sku (BinarySortField#getIndexSorter passes getSortKeyDocValues to the sorter, BinarySortField.java:178-182). The update of price is accepted, the documents do not move, and the segment still declares the sort.
  4. Code that trusts the declared order goes wrong. TopFieldCollector checks 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). MultiSorter merges 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).
  5. 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#initializeFieldInfo calls validateIndexSortDVType for 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.

The numbers

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 BinarySortField subclass overriding getSortKeyDocValues (10.5.0+, @lucene.experimental), or any custom SortField#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 a BinarySortField'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's MinValueSortField javadoc: "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, searchAfter pages are wrong (bounded-exhaustive harness), and merges and later addIndexes write 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.

Minimal repro

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.

TestDerivedSortKeyDocValuesUpdate.java (JUnit test)120 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.index;

import 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);
      }
    }
  }
}
Command
# 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.

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

More files, environment, full output44 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected 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)

Why it looks unintended

  • setIndexSort promises the sort "for all (flushed and merged) segments", and getIndexSortFields "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 to updateBinaryDocValue: "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. Neither BinarySortField, SortField#getIndexSorter, IndexSorter, SortFieldProvider, setIndexSort nor 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 BINARY price rejected under a sort that reads NUMERIC price ("expected field [price] to be NUMERIC"). The update guard does not. CheckIndex, Lucene's own checker, calls the result an error.
  • Tests. TestIndexSorting#testBadDVUpdate asserts 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".

Suggested fix

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.

Protect every field the sorters read (validated) (recommended)

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, with maxDoc 0.
  • The probe calls getIndexSorter() a second time and swallows any RuntimeException it throws, which matters only for a stateful or side-effecting sorter.
  • getIndexSortFields() now returns an unmodifiable set instead of the Collectors.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 validateIndexSortDVType instead 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, TestMixedDocValuesUpdates and TestSortedNumericDocValuesUpdates pass (188 tests, 2 skipped, 0 failures).
  • With the fix: the full lucene/core test 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:ecjLint and :lucene:core:checkGoogleJavaFormat pass.
  • 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; TestIndexSorting and TestBinarySortField pass (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.

Let a sort field declare the fields it reads (not run)

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.

Document the rule (not run)

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.

One "fields the index sort depends on" check for all known gaps (not run)

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.

Downstream note

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.

How it was found

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), addIndexes of 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, searchAfter pages 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; the dev@, java-user@, issues@ and commits@ lists; the JIRA archive2026-10-27three 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 runno report; context only: #16202, #10370, LUCENE-7581
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr), lists, web2026-10-27about 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#152504no 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 PRs2026-10-27changed files of all 408 open PRs; the 29 that touch the guard or sort files grepped for the guard identifiersonly #16810 changes the guard

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedcompact JUnit 3 of 4 fail through gradle, assertions on and off; control passes
10.5.0, 10.5.1, 10.5.2 affectedthe 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 formthe 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 affectedcannot be expressed: no IndexSorter; a custom index sort is rejected and the built-in sort field is guarded
fix (scratch worktree at a9779ef3c193) not affectedthe price update is rejected; 5 of 5 tests pass, assertions on and off; full lucene/core suite passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn 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 holdsBuilt 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, highRe-ran the independent reproduction's test with a new seed: 5 of 7 fail with assertions on and off.
Registration check and audit reproduces; keepRe-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 validatedCompact 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.

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

Finding record

LFV-759, snapshot of 2026-11-06

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 full lucene/core suite 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.

Lucene and upstream sources

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-515
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:417-420
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1664, 1721, 1938, 1965, 2003, 2041, 2070, 2081, 2098
  • lucene/core/src/java/org/apache/lucene/search/BinarySortField.java:41-45, 50, 149-158, 178-182
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:59, 93-104, 151-172
  • lucene/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-40
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114, 1146-1149, 1274-1282, 4742-4754
  • lucene/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:504
  • lucene/core/src/java/org/apache/lucene/index/LiveIndexWriterConfig.java:417
  • lucene/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-158
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1635-1727
  • lucene/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)

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

Repro files

About the diagrams

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.

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.