All findings · Lucene finding · LFV-1051 · overlay-sortednumeric-readvance-stale-values

Sorting on a multi-valued SORTED_NUMERIC field returns the wrong top hits after updateSortedNumericDocValue (doc-values overlays)

On main, with doc-values overlays on by default, one updateSortedNumericDocValue on a multi-valued field breaks SortedNumericSortField: documents {50, 60}, {10, 90}, {30, 40} with doc 2 set to 70 sort doc 2 first, not doc 1. The overlay view does not restart a document's values when sorting reads it twice.

Summary

Priority
Medium, rank 95Deterministic silent wrong top hits for SortedNumericSortField, on by default only on unreleased main and opt-in behind the experimental overlay in 10.6.0, and only after a single-valued update on a field that is really multi-valued and only until a merge.
Impact
silent wrong results
Since
10.6.0, opt-in (#16581, merged 2026-09-21); on by default on mainintroduced 2026-09-21
Affected
main with the default config; 10.6.0 with setMaxDocValuesOverlays(n > 0); 10.5.2 and older lack the API
Status
documented
Found by
code reading and a bounded-exhaustive check of the overlay merger, then a JUnit

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

After the update, the segment reads m through OverlaySortedNumericDocValues: the update layer over the multi-valued base column. Its advanceExact moves only layers that are behind the target, so a second advanceExact on the current document leaves the base column's value cursor where the first read stopped.

Background: doc-values overlays and how a sort reads a value

  1. A SORTED_NUMERIC field stores a sorted list of numbers per document. You read it with advanceExact(doc), then nextValue() up to docValueCount() times. Calling advanceExact again on the same document is allowed, and Lucene's own sorting relies on it starting the values over.
  2. With overlays on (main's default), a doc-values update is not a rewrite of the column: it is a small layer holding only the updated documents, merged with the old column at read time until the segment is merged (or the updates are folded into one full column).
  3. SortedNumericSortField keys each document by its minimum (or maximum). Once the top-n queue is full, TopFieldCollector reads every competitive document twice, compareBottom(doc) then copy(slot, doc), and each read starts with advanceExact(doc).
Summary card for the repro: documents {50, 60}, {10, 90}, {30, 40}, doc 2 updated to 70; the top hit by minimum should be doc 1 (10) but is doc 2 (70), because the second read of doc 1 returns 90.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index three documents with a multi-valued SortedNumericDocValuesField m: doc 0 = {50, 60}, doc 1 = {10, 90}, doc 2 = {30, 40}. Then call updateSortedNumericDocValue(new Term("id", "2"), "m", 70) with the default IndexWriterConfig on main: the update is stored as a small overlay layer on top of the old column.
  2. Search all documents for the top 1 by new SortedNumericSortField("m", LONG), which sorts by each document's minimum. You expect doc 1: 10 is smaller than 50 and 70. With overlays off, that is what you get.
  3. The collector reads a competitive document twice: compareBottom reads doc 1 as 10, then copy calls advanceExact(1) again. The overlay view does not touch a layer that already sits on doc 1, so the old column's value cursor is not reset and the second read returns 90.
  4. Doc 1 is stored with sort key 90, doc 2 (70) beats it, and you get doc 2, with no error. With a MAX sort the stale cursor can also run past the end of the column and throw.

Who is affected

Applications on main (default) or on 10.6.0 with setMaxDocValuesOverlays(n > 0) that call updateSortedNumericDocValue on a field with multi-valued documents and sort on it. Such sorts can be wrong until the segment is merged, and the documents you did not update get the wrong keys. Single-valued fields and NUMERIC/BINARY updates are unaffected; data on disk is correct.

Mechanism, terms and numbers814 words

With doc-values overlays (IndexWriterConfig#setMaxDocValuesOverlays, @lucene.experimental; on by default on main with 8 overlays, opt-in in 10.6.0), a set-only doc-values update is written as a sparse layer and merged with the old column at read time. After IndexWriter#updateSortedNumericDocValue (new in 10.6.0, #16581) on a segment whose SORTED_NUMERIC field is multi-valued in some document, the segment reads the field through OverlaySortedNumericDocValues. Its advanceExact delegates to DocValuesOverlayMerger#advanceExact, which moves only layers whose docID() < target; on advanceExact(docID()) nothing moves, so nextValue() continues on the base column where the previous read stopped instead of restarting the document's values. DocValuesIterator#advanceExact allows target == docID() on purpose (LUCENE-7462: comparators read a value more than once), and TopFieldCollector does exactly that for every competitive hit (compareBottom, then copy). So SortedNumericSortField sorts can return wrong top hits and wrong sort values, silently and the same way every time for the same index and search, until a merge (or a fold to one dense column) rewrites the segment; a MAX sort can instead throw IndexOutOfBoundsException or RuntimeException(EOFException). Three documents and one update suffice: the top hit by minimum is doc 2 instead of doc 1. The documents that go wrong are the ones that were not updated; data on disk is correct. Fields that are single-valued everywhere and NUMERIC/BINARY overlays are not affected. A one-method fix in OverlaySortedNumericDocValues#advanceExact (re-position the winning layer when the target is the current document) passes the test and the doc-values-update, overlay and sort suites.

The mechanism (main 645d4fe14a20)

  1. The view. SegmentDocValuesProducer#getSortedNumeric returns the base iterator when a field has one producer, else OverlaySortedNumericDocValues.from(...) (SegmentDocValuesProducer.java:218-224). from takes the singleton OverlayNumericDocValues path only when every layer is single-valued (OverlaySortedNumericDocValues.java:79-89), else builds OverlaySortedNumericDocValues (:91), which "works whether the base column is single- or multi-valued" (:23-28).
  2. Delegation. nextValue() and docValueCount() read layers[merger.valueLayer()] (:41-49); advanceExact(target) is merger.advanceExact(target) (:51-54).
  3. The merger (DocValuesOverlayMerger.java:72-80):
   while (layers[heap[1]].docID() < target) {
     layers[heap[1]].advance(target);
     siftDown();
   }
   docID = target;
   winner = layers[heap[1]].docID() == target ? heap[1] : -1;

A layer already on target is not touched. The class javadoc says layers "are advanced with advance only (never advanceExact) so iteration and random access can be interleaved" (:28-29); it says nothing about re-reading the current document.

  1. The base column's cursor. In the default codec's dense multi-valued iterator, advanceExact sets start/end from the address table and nextValue() is values.get(start++) with no end check (Lucene90DocValuesProducer.java:2061-2072). Not re-positioned, a second read continues with the document's next value, then the next document's values (a neighbour's, or the pre-update values of an updated document), or runs past the end of the column.
  2. The consumer. LongLeafComparator#getValueForDoc calls advanceExact(doc) (LongComparator.java:79-85) for compareBottom, compareTop and copy (:93-107), through SortedNumericSelector.MinValue (SortedNumericSelector.java:135-142: one nextValue()) or MaxValue (:169-174, :194-201: docValueCount() values). Once the queue is full, TopFieldCollector calls compareBottom(doc) (TopFieldCollector.java:93) and, for a competitive hit, copy(bottom.slot, doc) (:116). The first n documents only get copy (:122-126), one read.

The repro, read by read

A traced run of the three documents (main, assertions off): copy(doc 0) reads 50, the bottom is 50. compareBottom(doc 1) reads 10: competitive. copy(doc 1) calls advanceExact(1) again; the base layer stays on doc 1 with its cursor after 10, so nextValue() is 90 and doc 1 is stored with key 90. compareBottom(doc 2) reads 70 from the update layer; 70 < 90, so doc 2 replaces doc 1. Top hit: doc 2, key 70. With setMaxDocValuesOverlays(0): doc 1, key 10.

Variants (same mechanism)

  • Direct re-read: advanceExact(1), nextValue() = 10, advanceExact(1), nextValue() = 90.
  • A MAX sort (reverse) on the same three documents returns doc 2 (70) instead of doc 1 (90).
  • Two documents {5} (updated to 1) and {10, 90}, MAX descending, top 1: IndexOutOfBoundsException on a ByteBuffersDirectory, RuntimeException(EOFException: read past EOF) on an FSDirectory. Correct with overlays off.
  • Wrong sort keys: with doc 0 updated to 70 instead, the MIN top hit is doc 2 with key 40 (its true minimum is 30); with doc 0 updated to 1, a MAX sort returns the right hit, doc 1, but with key 40, doc 2's value.
  • searchAfter: page 2 after (doc 1, key 10) returns doc 1 again, already on page 1, with key 30 (doc 2's pre-update value); with overlays off it returns doc 0, key 50.
  • Updated documents read correctly: their winning layer is the single-valued update, whose nextValue() returns the same value every time. The wrong keys go to documents that were not updated.
  • Not affected: NUMERIC and BINARY overlays (they have no per-document value cursor) and SORTED_NUMERIC fields that are single-valued in every document of the segment (singleton path): 12,846 re-read checks over 200 random indexes, 0 mismatches.
Exposure and likelihood439 words
  • Who is exposed. Users of the new IndexWriter#updateSortedNumericDocValue (or updateDocValues with a SortedNumericDocValuesField) on a field that is multi-valued in at least one document of the segment; the javadoc and CHANGES both allow a multi-valued field ("The field itself may be single- or multi-valued"). On main this is the default configuration; in 10.6.0 it needs the experimental setMaxDocValuesOverlays(n > 0).
  • Effect. Silent wrong top hits and wrong sort values (FieldDoc.fields) for SortedNumericSortField with MIN or MAX. It shows when the collector re-reads a competitive document that was not updated, so that its values come from the multi-valued base column; that needs more matches than the requested top n (or searchAfter); whether the hits or keys then come out wrong depends on the values, and the same index and search give the same wrong result every time. Sometimes loud: with MAX the stale cursor can run past the column and fail the query (IndexOutOfBoundsException or RuntimeException(EOFException)).
  • How long. Until the segment is merged, or until a fold rewrites the field as one dense column, for readers opened after that (a reader that is already open keeps its view). A fold happens while writing the next set-only update once the field already has maxDocValuesOverlays delta generations; it is a dense rewrite, one producer that reads correctly, when the prior deltas' summed docs-with-value counts reach half the segment's maxDoc. Otherwise a sparse fold keeps the base plus one folded generation, still read through the overlay view.
  • Scale. Three documents and one update suffice. In a random probe with 300 documents of 1 to 3 values each (2 to 11 segments) and 400 single-valued updates (6 seeds, overlay limits 1, 2 and 8), a second read was wrong for 71 to 227 of the 300 documents in each run, while single reads were right in all 73,200 checks and CheckIndex was clean.
  • Not affected. Fields that are single-valued in every document of the segment (the motivating case of #16580), NUMERIC and BINARY overlays, overlays off, and persisted data: in the same probe every single read was right, after the updates and after reopening the writer with overlays off. Merges ran in only 3 of its 18 runs, so for merges the evidence is mainly upstream tests such as TestSortedNumericDocValuesUpdates#testUpdateKeepsOtherDocsMultiValuedAcrossMerge, which pass with the fix.
  • Also executed. searchAfter paging, which reads each hit with compareTop and then copy (with compareBottom first once its queue is full): page 2 can return a hit from page 1 again. Application code that calls advanceExact twice on a document to read its values again gets the direct re-read above.

Minimal repro

One JUnit test in lucene/core, public API, default IndexWriterConfig and codec: three documents, one update, a top-1 SortedNumericSortField search. It fails on main with assertions on and off; on 10.6.0, add .setMaxDocValuesOverlays(8) to see it.

TestOverlaySortedNumericSort.java (lucene/core test, one method)62 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.SortedNumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.search.SortedNumericSortField;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestOverlaySortedNumericSort extends LuceneTestCase {

  public void testMinSortAfterUpdate() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        w.addDocument(doc("0", 50, 60));
        w.addDocument(doc("1", 10, 90));
        w.addDocument(doc("2", 30, 40));
        w.commit();
        w.updateSortedNumericDocValue(new Term("id", "2"), "m", 70);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        Sort sort = new Sort(new SortedNumericSortField("m", SortField.Type.LONG));
        TopDocs top = new IndexSearcher(r).search(MatchAllDocsQuery.INSTANCE, 1, sort);
        // expected: doc 1 (min 10) on top; actual on main: doc 2 (doc 1 is re-read as 90)
        assertEquals(1, top.scoreDocs[0].doc);
      }
    }
  }

  private static Document doc(String id, long... values) {
    Document doc = new Document();
    doc.add(new StringField("id", id, Field.Store.NO));
    for (long v : values) {
      doc.add(new SortedNumericDocValuesField("m", v));
    }
    return doc;
  }
}
Command
cp $REPRO/TestOverlaySortedNumericSort.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestOverlaySortedNumericSort -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
Top hit doc 1: its minimum, 10, is smaller than doc 0's 50 and doc 2's 70.
Observed
Top hit doc 2: AssertionError: expected:<1> but was:<2>, with assertions on and off.

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

More files, environment, full output219 words

The test sits in org.apache.lucene.index like the upstream doc-values update tests but uses only public classes and methods. It pins the default codec (Lucene104, whose doc values use the Lucene90 format; the only codec run); newDirectory() picks the Directory by seed, and every seed tried fails (DEADBEEF through gradle and the standalone runner, assertions on and off; 1, C0FFEE and 5EED42 on the standalone runner, assertions off). With setMaxDocValuesOverlays(0) the same scenario passes.

Suggested regression test: this same test, for example as a method of TestSortedNumericDocValuesUpdates.

On 10.6.0 overlays are off by default (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 0), so the test passes there as written; with .setMaxDocValuesOverlays(8) added to the IndexWriterConfig it fails with the same message. On 10.5.2 it does not compile (updateSortedNumericDocValue does not exist).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also 10.6.0 with setMaxDocValuesOverlays(8)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)

Observed output

TestOverlaySortedNumericSort > testMinSortAfterUpdate FAILED
    java.lang.AssertionError: expected:<1> but was:<2>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:75239423F2B9FA40]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.index.TestOverlaySortedNumericSort.testMinSortAfterUpdate(TestOverlaySortedNumericSort.java:49)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false, and on the standalone runner with -ea and -da
# 10.6.0 + setMaxDocValuesOverlays(8): the same failure; 10.6.0 default: passes
# with the recommended fix: 1 test, 0 failures

Why it looks unintended

  • advanceExact allows target == docID() on purpose: LUCENE-7462 chose >= so comparators can read a value more than once, and TopFieldCollector does.
  • The default codec, the asserting test wrapper and MemoryIndex (fixed for this in LUCENE-7890) restart the values; with overlays off the index sorts correctly.
  • The update API allows multi-valued fields, and overlays are only a storage choice.
The full argument308 words
  • Contract. DocValuesIterator#advanceExact: "target must be greater than or equal to the current doc ID" (DocValuesIterator.java:24-28). Adrien Grand on LUCENE-7462 (#8514, 2016-10-20): "Initially I wanted to require that the target is strictly greater than the current doc id but this caused issues with comparators that may need to get the value multiple times [...] So the current patch only requires that the target is greater than or equal to the current document."
  • The documented way to re-read. In the 2018 java-user thread "Read DocValue twice", the same maintainer answered: "If you want to read the values again, you need to call setDocument (Lucene < 7.0) or advanceExact (Lucene >= 7.0) before calling nextValue()".
  • Same defect class, fixed before. LUCENE-7890 (#8941, 2017): "The SortedSetDocValues and SortedNumericDocValues instances returned by the MemoryIndex should support subsequent advanceExact(0) invocations"; its test TestMemoryIndex#testDocValues_resetIterator is still on main and calls advanceExact(0) three times, expecting all five values each time (TestMemoryIndex.java:327-366). LUCENE-8117 (#9165, 2018) fixed advanceExact returning invalid SORTED_NUMERIC values in the Lucene54 producer.
  • The other implementations checked restart. The default codec recomputes start on each advanceExact, in the dense (Lucene90DocValuesProducer.java:2061-2067) and the sparse multi-valued iterator (:2184-2198, :2263-2271); MemoryIndex does since LUCENE-7890; AssertingSortedNumericDocValues#advanceExact accepts target >= in.docID() and resets its value budget, valueUpto = 0 (AssertingLeafReader.java:1176-1187). No implementation that continues the cursor was found besides the overlay view (not an exhaustive audit).
  • Overlays are a storage choice. setMaxDocValuesOverlays trades read cost for write amplification (IndexWriterConfig.java:335-342); #16581's description says supporting a multi-valued base is intended ("really it's not hard to support multi-valued SortedNumericDocValues"). With overlays off the same data sorts correctly.
  • Why tests missed it. TestSortedNumericDocValuesUpdates never calls advanceExact and uses SortedNumericSortField only to check that an index-sort field is rejected; the overlay and update tests read each document once.
The devil's-advocate check145 words

Strongest case (the intended-lens reviewers and the adjudication): the merger documents an advance-only design; the SortedNumericDocValues javadoc only says "Do not call this more than docValueCount times for the document" and never says that a repeated advanceExact restarts the values; overlays are @lucene.experimental. So continuing the cursor could be called allowed.

Why it fails: the advance-only rule is about moving layers ("so iteration and random access can be interleaved"), not about re-reading a document; the >= contract exists for comparators that read a value more than once, and core TopFieldCollector is that caller, so the view breaks Lucene's own sorting, not an outside misuse; @lucene.experimental marks API instability, and the feature is on by default on main. A maintainer could also document the restart explicitly, but documentation alone would not repair TopFieldCollector on overlays: a code change is still needed, preferably the overlay-local one.

Suggested fix

Recommended Restart the winning layer in OverlaySortedNumericDocValues#advanceExact

In OverlaySortedNumericDocValues.advanceExact, when the target is the current document, call advanceExact(target) on the winning layer so its values start over (+8/-1, one file; the layer already sits on the target, so nothing moves). The test and 10 doc-values-update, overlay and sort suites (138 tests) pass, with assertions on and off.

  @Override
  public boolean advanceExact(int target) throws IOException {
    final boolean sameDoc = target == merger.docID();
    final boolean found = merger.advanceExact(target);
    if (found && sameDoc) {
      // advanceExact(docID()) must restart the document's values, but the merger does not move a
      // layer that already sits on target: re-position the winning layer (target == its docID()).
      layers[merger.valueLayer()].advanceExact(target);
    }
    return found;
  }
All options and trade-offs4 options ยท 510 words

Two forms of the same restart were built and run in a scratch worktree of 645d4fe14a20: in the view (recommended) and in the shared merger. A value-buffering design and a documentation-only change are listed for completeness.

Restart the winning layer in OverlaySortedNumericDocValues#advanceExact (validated by the checks) (recommended)

When the target is the current document and a layer has it, call advanceExact(target) on the winning layer, so its values start over (OverlaySortedNumericDocValues.java, +8/-1). The idea is the first finder's.

  @Override
  public boolean advanceExact(int target) throws IOException {
    final boolean sameDoc = target == merger.docID();
    final boolean found = merger.advanceExact(target);
    if (found && sameDoc) {
      // advanceExact(docID()) must restart the document's values, but the merger does not move a
      // layer that already sits on target: re-position the winning layer (target == its docID()).
      layers[merger.valueLayer()].advanceExact(target);
    }
    return found;
  }

Trade-offs

  • Safe by construction: the winning layer sits on target with a value, so advanceExact(target) is legal, returns true and leaves it there; the merger's heap and its advance-only rule for moving layers are unchanged. NUMERIC and BINARY overlays are untouched.
  • Cost: one extra advanceExact on one layer, only when the same document is positioned again. No benchmark was run.

What the checks validated

  • Stock + test: fails through gradle with assertions on and off (expected:<1> but was:<2>); + fix: passes.
  • + fix: 10 suites (the test, TestSortedNumericDocValuesUpdates, TestIncrementalDocValuesUpdates, TestMixedDocValuesUpdates, TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestOverlayNumericDocValues, TestOverlayBinaryDocValues, TestSortedNumericSortField, TestTopFieldCollector): 138 tests, 2 skipped, 0 failures, with assertions on and off; :lucene:core:check -x test passes.
  • In the hunt: the bounded-exhaustive check finds 0 counterexamples with this fix, and the independent verifiers' and reviewers' own tests pass with it.
  • The independent fact check repeated these gradle runs in its own scratch worktree with the same results.
  • Not run: the full Lucene test suite, the nightly tests, any benchmark.

The same restart inside DocValuesOverlayMerger#advanceExact (validated by the checks)

When target == docID and a layer wins, call layers[winner].advanceExact(target); reword the class javadoc, which says "never advanceExact" (+10/-2).

Trade-offs

Covers any future multi-valued overlay built on the merger. Adds the extra call to NUMERIC and BINARY re-reads, which do not need it, and changes a class shared by all overlay types; a committer may still prefer it as the single place that owns positioning.

What the checks validated

  • + this fix instead: the same 10 suites, 138 tests, 2 skipped, 0 failures, with assertions on (not run with assertions off or through check).

Buffer the current document's values in the view (not built)

Copy the winning layer's docValueCount() values into an array on every positioning and serve nextValue() from it.

Trade-offs

Independent of how layers behave, but adds a copy for every document positioned, whether it is read once or twice.

Document that advanceExact(docID()) does not restart the values (not recommended)

Change the javadoc instead of the code.

Trade-offs

Contradicts the LUCENE-7462 contract and leaves TopFieldCollector broken on overlays: documentation alone would not repair the collector, so a code change is still needed.

Downstream note

A returned sort value can come from a neighbouring document of the same field and segment, which matters only to an application that shows sort values to users and hides that neighbour through its own authorization.

How it was found

A code-reading hypothesis about the overlay merger's value cursor, confirmed by a bounded-exhaustive check: every short sequence of moves on tiny segments, run on the real merger and view, disagreed with an independent reference exactly when advanceExact was repeated on the current document. An Alloy model found it independently; both led to a public-API JUnit.

The method in full154 words

Method: semi-formal (a code-reading hypothesis witnessed by bounded-exhaustive translation validation), a hunt unit, 2026-10-25. The harness drives the real DocValuesOverlayMerger and OverlaySortedNumericDocValues through every legal move sequence (nextDoc, advance, advanceExact) over up to 3 layers and 5 documents, compares every read with an independently written union reference, and repeats with advanceExact(docID()) allowed and forbidden. Re-run on 645d4fe14a20: 38,874 layer assignments, 386,132 reachable merger states; SORTED_NUMERIC counterexamples only when the repeated advanceExact is allowed, NUMERIC 0 in both modes, 0 everywhere with the fix.

Independently, a hunt unit built an Alloy 6 model of the overlay read protocol, got a 4-step counterexample and confirmed it with a differential test on the real jar. Later units (a hunt unit, a hunt unit with the MAX-sort exception, and round-17 probes) saw it again; the adjudication collapsed all of them into this row and the no-noise gate reduced the repro to one test.

Prior reports, history and checks

Prior reports

No prior report found in the searches listed: GitHub issues, PRs and commits (Lucene and three downstream projects), the Lucene mailing lists, the JIRA archive and the web, searched 2026-10-29 and refreshed 2026-10-30. The same defect class was fixed long ago in MemoryIndex (LUCENE-7890) and the Lucene54 producer (LUCENE-8117).

Searches4 searches

Context, not reports: #16581 / #16580 introduced it (one review comment raised five numbered points and a second reviewer left one inline comment, none about re-reading the current document); #16418 / #16554 / #16550 added overlays and the merger; #16644, #16645, #16648, #16714, #16566, #16872 and #16923 are other overlay or doc-values-update items. No open PR touches the two overlay classes. Search limits: keyword search only; GitHub search does not index PR diffs (open PRs were checked by changed files); the mailing-list index misses dotted stack-trace tokens.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr)2026-10-29two scripted prior-report searches (each 16 issue/PR, 3 commit, 2 downstream, 20 mailing-list and 4 JIRA-archive queries, and 11 and 12 web queries; class and method terms; symptom terms such as advanceExact twice, wrong sort, IndexOutOfBoundsException) and 50 manual queries; full discussions of #16418, #16581, #16580, #16554, #16550 and later overlay itemsno report; only the introducing PRs and other overlay items
Open apache/lucene PRs and commit history2026-10-29changed files of all 409 open PRs; commits touching both fix-site files on main, branch_10x and branch_10_6no open PR touches the overlay classes; only #16418 / #16554 and #16581 touched the files
Mailing lists, JIRA archive, web2026-10-29dev@, java-user@, issues@, commits@, builds@; LUCENE-7462, LUCENE-7890, LUCENE-8117, LUCENE-8138 readno report (the overlay code postdates the JIRA era); the 2018 java-user thread states the re-read contract
Refresh2026-10-30upstream heads; commits to both files on main and branch_10_6; issue and PR search for the class names, updateSortedNumericDocValue, "overlay advanceExact", "doc values overlays sort"no new item (an earlier search for overlay advanceExact, 2026-10-29, returned only #16418, the change that added overlays); both files unchanged since #16581 and #16418 / #16554

History

Introduced by #16581 (169cbffd12f, merged 2026-09-21), which added updateSortedNumericDocValue and the multi-valued overlay view on top of #16418's merger. Affects main by default and 10.6.0 with overlays opted in; 10.5.2 and older lack the API.

Versions tested7 rows

#16581 "Allow single-valued updates for SortedNumericDocValues" (HoustonPutman, for their issue #16580) is 169cbffd12f on main and eaa3a7a9edb on branch_10_6, both 2026-09-21. It added the update API and OverlaySortedNumericDocValues, whose nextValue() reads the winning layer's cursor. The merger's advance-only advanceExact is older: #16418 (75eddfaff22, jimczi, 2026-08-13; 10.x backport #16554 3a6cd003aaf). It was harmless there, because the NUMERIC and BINARY views read the current value without a cursor. Both files are identical in 10.6.0 (4ae946e5a9b) and 645d4fe14a20. 10.6.0 ships overlays off by default (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 0); main turned them on (8).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (11.0.0-SNAPSHOT), default config affectedthe test fails, assertions on and off (gradle and standalone, 4 seeds)
upstream main 716d6f216f52, branch_10_6 head readsame code: OverlaySortedNumericDocValues last changed by #16581 on both; DocValuesOverlayMerger by #16418 on main and its backport #16554 on branch_10_6 (2026-10-30)
10.6.0 (4ae946e5a9b) with setMaxDocValuesOverlays(8) opt-inthe test fails: expected:<1> but was:<2> (release jars, assertions off)
10.6.0 (4ae946e5a9b), default config overlays offpasses
169cbffd12f (#16581) vs its parent 2165146d01c bisect#16581 fails (second read 90, MIN top hit doc 2); its parent has no update API (compile error)
10.5.2, 9.12.3 API absentthe test does not compile: updateSortedNumericDocValue does not exist
with the fix (main 645d4fe14a20) not affectedthe test and 10 doc-values-update, overlay and sort suites pass

Independent checks

The finder, cross-model reviews (OpenAI GPT-5.6, with code execution), more verifiers and an adjudication rate it real; the adjudication re-ran it on main. A credibility audit lowered the impact score and kept the claim. This doc's runs reproduced it and validated the fix.

Checks and verdicts430 words

Every check re-ran a repro on stock jars. The cross-model reviewers wrote and ran their own tests (one outside Lucene's packages, public API only); their checkout ea57360912ed has identical sources for every class on the read and sort path (SortedNumericSortField differs only in a deserialization branch, #16919, not on the sort path), and both reviewers' tests fail the same way on 645d4fe14a20 (the intended-lens test in the verification, the refute-lens tests in the fact check).

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit, first find; the adjudication) candidateBounded-exhaustive check: SORTED_NUMERIC counterexamples only when advanceExact(docID()) is allowed, 0 with the fix; the repro fails on main with -ea and -da, passes with overlays 0 and with the fix.
cross-model review, refute lens (OpenAI GPT-5.6) real, highTwo own tests reproduce the wrong re-read and the wrong MIN top hit with -ea, -da and 4 seeds; overlays-0 controls pass; the fix passes.
Independent reproducer realOwn public-API tests: MIN top hit doc 2 instead of doc 1, re-read 90 instead of 10, MAX test throws; 10.6.0 fails only with overlays opted in; the fix passes all three.
Cross-model review, intended lens (OpenAI GPT-5.6, effort high) real, highThe works-as-designed case is weak; its own test fails 2 of 3 on main and on 10.6.0 with overlays 8.
Devil's advocate real, highNo intended-behaviour argument survives the contract, LUCENE-7462, LUCENE-7890, the asserting wrapper and core sorting.
adjudication confirmed, highRe-ran the finder's repro (-ea, -da) and the independent reproduction's test (-da) on main: all fail.
credibility audit and no-noise gate claim; impact 80 -> 70The sort-only form fails with -da and passes with the fix; the score was recomputed per scenario (default only on unreleased main, opt-in in 10.6.0).
this doc's runs reproduced, fix validatedThe one-method test on main (gradle and standalone, assertions on and off, 4 seeds), on 10.6.0 (default and opt-in) and 10.5.2; both fix forms with the suites.
Independent fact check real (2026-10-30)The doc's gradle command fails in its own scratch worktree with assertions on and off, with the same output; both fixes pass; 10.6.0 fails only with the opt-in; its own probe executed the wrong-key and searchAfter variants. Five small text errors, fixed in this doc.
Second fact check (OpenAI GPT-5.6, read-only) sound; wording fixedChecked the brief against saved sources and run records (no execution): substantively sound; two medium precision errors, three overclaims and seven missing caveats, all addressed.

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

Finding record

LFV-1051, snapshot of 2026-11-06

Registry title
With doc-values overlays (on by default on main), a second advanceExact on the same document does not restart a multi-valued SORTED_NUMERIC column's values, so SortedNumericSortField sorts return the wrong top hits (or throw)
Area
index / doc-values overlays (OverlaySortedNumericDocValues over DocValuesOverlayMerger), seen through search / field sorting (SortedNumericSelector, LongComparator, TopFieldCollector)
Priority
Medium, rank 95: Deterministic silent wrong top hits for SortedNumericSortField, on by default only on unreleased main and opt-in behind the experimental overlay in 10.6.0, and only after a single-valued update on a field that is really multi-valued and only until a merge.
Impact class
silent wrong results
Impact note
Applications that call updateSortedNumericDocValue (or updateDocValues with a SortedNumericDocValuesField) on a SORTED_NUMERIC field whose segment holds at least one multi-valued document, and then sort on that field with SortedNumericSortField (MIN or MAX) or read a document twice through advanceExact, get wrong top hits and wrong sort keys with no error (with MAX sometimes an IndexOutOfBoundsException or EOFException instead) until a merge rewrites the segment; this is the default configuration on main (11.0.0-SNAPSHOT, overlays on by default) and needs the @lucene.experimental setMaxDocValuesOverlays(n > 0) in the released 10.6.0; three documents and one update suffice, while all-single-valued SORTED_NUMERIC fields (the motivating case of #16580) and NUMERIC/BINARY overlays are not affected.
Summary
You index three documents with a multi-valued SortedNumericDocValuesField m = {50,60}, {10,90}, {30,40}, set doc 2 to the single value 70 with IndexWriter.updateSortedNumericDocValue (default IndexWriterConfig on main), and sort all documents by new SortedNumericSortField("m", LONG), which keys each document by its minimum value. You expect doc 1 first (minimum 10), which is what you get with overlays disabled. You get doc 2: the overlay view ignores a second advanceExact on the document it already sits on, so when the collector reads doc 1 again its value cursor continues at 90 instead of restarting at 10, and with a MAX sort the cursor can run past the end of the column and throw.
Introduced
169cbffd12f4579ad8bd2906a4b70e2e70130711 (apache/lucene#16581 'Allow single-valued updates for SortedNumericDocValues', by HoustonPutman for their issue #16580; merged on main 2026-09-21; branch_10_6 cherry-pick eaa3a7a9edb the same day) [exec: commit-level bracket with core jars built from each commit: the parent 2165146d01c cannot express the trigger (updateSortedNumericDocValue does not exist: compile error), 169cbffd12f fails (second read of doc 1 = 90, MIN top hit doc 2)]. The defective step is older: DocValuesOverlayMerger.advanceExact, which moves only layers behind the target, comes from #16418 (75eddfaff22, jimczi, 2026-08-13; 10.x backport #16554 = 3a6cd003aaf) and was harmless there because the NUMERIC and BINARY overlay views have no per-document value cursor [read; NUMERIC/BINARY re-reads checked on main: 0 mismatches]. #16581 added OverlaySortedNumericDocValues, whose nextValue() reads the winning layer's cursor [read]. (2026-09-21 (#16581, author and merge date on main and branch_10_6; the merger logic dates from #16418, 2026-08-13))
First affected version
10.6.0 [exec] (only with the @lucene.experimental opt-in IndexWriterConfig#setMaxDocValuesOverlays(n > 0); the 10.6.0 default of 0 overlays is not affected). On main (11.0.0-SNAPSHOT, unreleased) overlays are on by default (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 8).
Affected versions
[exec] main 645d4fe14a20 with the default IndexWriterConfig (-ea and -da; finder seeds DEADBEEF, 1, C0FFEE and 5EED42, seed 5EED7A11), the introducing commit 169cbffd12f, and 10.6.0 with setMaxDocValuesOverlays(8) (published jars, SHA-1 verified: the repro fails with -ea, the independent reproduction's two tests fail 2 of 2 with -da, the GPT intended-lens test 2 of 3; also a core jar built from 4ae946e5a9b). Released in 10.6.0: the tag releases/lucene/10.6.0 exists (tag object e29d290eeb05, peels to 4ae946e5a9b; git ls-remote 2026-10-29). [read] upstream main head 716d6f216f5 and branch_10_6 head 7e5aec4370e: DocValuesOverlayMerger.java and OverlaySortedNumericDocValues.java unchanged since #16581 (GitHub commit history re-checked 2026-10-29). Not affected [exec]: 10.6.0 at its default (0 overlays, passes); 10.5.2, 9.12.3 and main before 169cbffd12f (the update API does not exist: compile error). Not affected by this mechanism on any version [exec on main]: NUMERIC and BINARY overlays and all-single-valued SORTED_NUMERIC columns (singleton path), 12,846 re-read checks, 0 mismatches.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by Alloy, exhaustive check
Upstream and prior reports
none found (2026-10-29, read-only; the prior-report record verdict new): two scripted prior-report searches (each 16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries and web), 50 manual GitHub search-API queries (apache/lucene, Elasticsearch, OpenSearch, Solr) plus code search for setMaxDocValuesOverlays downstream (0 hits), the changed files of all 409 open apache/lucene PRs and of every PR updated since 2026-10-26 (none touches the overlay classes), the commit history of both fix-site files on main, branch_10x and branch_10_6 (author and committer dates: only #16418 and #16581), the full discussions of #16418, #16581, #16580, #16554, #16550 and the overlay follow-ups, lists.apache.org (dev@, java-user@, issues@, builds@), the JIRA archive and web search; the candidate's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches agree; refresh 2026-10-29: no new item. Context, not reports: #16581 and #16580 (the introducing change; jimczi's review raised five points, none about re-reading the current document on the read-side overlay), #16418 / #16554 / #16550 (overlays and the merger), #16644, #16645, #16648, #16714, #16566, #16872, #16923 (other overlay or doc-values-update items). Precedents of the same defect class in other code, fixed long ago: LUCENE-7890 (#8941, MemoryIndex), LUCENE-8117 (Lucene54 producer); contract sources LUCENE-7462 (#8514), LUCENE-8138 (#9186) and the 2018 java-user thread 'Read DocValue twice'. Tracking issue #16937 and its cross-references do not mention overlays.
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT, commit of 2026-10-28): the repro test through gradle in a scratch worktree (assertions on and off), stock and with each fix, plus 10 doc-values-update, overlay and sort suites with the fixes; the standalone runner with -ea (seed DEADBEEF) and -da (seeds DEADBEEF, 1, C0FFEE, 5EED42) and with the recommended fix shadowed. 10.6.0 release jars (4ae946e5a9b, SHA-1 checked) at the default and with setMaxDocValuesOverlays(8); 10.5.2 release jars (compile error: no update API). Upstream main 716d6f216f52 and branch_10_6 were read on 2026-10-30: the last change to OverlaySortedNumericDocValues is #16581 on both, to DocValuesOverlayMerger #16418 on main and its backport #16554 on branch_10_6. An independent fact check (2026-10-30) repeated the gradle runs in its own scratch worktree with the same results. Only the default codec (Lucene104), Linux aarch64 and OpenJDK 25.0.3 were run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/DocValuesOverlayMerger.java:22-30 (at 645d4fe14a20) (class javadoc: layers are advanced with advance only, never advanceExact, 'so iteration and random access can be interleaved'), :72-80 (advanceExact: advances only layers whose docID() < target, then picks the winner; a layer already on target keeps its partly consumed value cursor)
  • lucene/core/src/java/org/apache/lucene/index/OverlaySortedNumericDocValues.java:23-28 (at 645d4fe14a20) (works whether the base column is single- or multi-valued), :41-54 (nextValue/docValueCount delegate to the winning layer; advanceExact delegates to the merger), :76-93 (all-single-valued layers take the singleton OverlayNumericDocValues path, else OverlaySortedNumericDocValues)
  • lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-33 (at 645d4fe14a20) (advanceExact: target 'must be greater than or equal to the current doc ID')
  • lucene/core/src/java/org/apache/lucene/index/SortedNumericDocValues.java:28-38 (at 645d4fe14a20) (nextValue/docValueCount are about 'the current document')
  • lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:224 (at 645d4fe14a20) and ReadersAndUpdates.java:658 (reads of an updated SORTED_NUMERIC field go through OverlaySortedNumericDocValues.from)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:115 (at 645d4fe14a20) (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 8), :335-348 (setMaxDocValuesOverlays: 'Enabled by default', @lucene.experimental, a read-cost versus write-amplification trade-off)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2020-2037 (at 645d4fe14a20) (updateSortedNumericDocValue: 'The field itself may be single- or multi-valued')
  • lucene/core/src/java/org/apache/lucene/search/SortedNumericSelector.java:135-142 (at 645d4fe14a20) (MinValue.advanceExact: in.advanceExact + nextValue), :169-174 and :194-201 (MaxValue reads docValueCount values)
  • lucene/core/src/java/org/apache/lucene/search/comparators/LongComparator.java:79-106 (at 645d4fe14a20) (getValueForDoc calls advanceExact(doc); compareBottom, compareTop and copy each call it)
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:93, (at 645d4fe14a20) :116 (a competitive hit is read by compareBottom and then copy)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2061-2071 (at 645d4fe14a20) (dense SORTED_NUMERIC advanceExact recomputes start/end; nextValue is values.get(start++) with no end check)
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:1176-1186 (at 645d4fe14a20) (AssertingSortedNumericDocValues.advanceExact accepts target >= in.docID() and resets valueUpto = 0)
  • lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndex.java:328-366 (at 645d4fe14a20) (testDocValues_resetIterator, LUCENE-7890: repeated advanceExact(0) re-reads all SORTED_NUMERIC values)
  • https://github.com/apache/lucene/pull/16581; https://github.com/apache/lucene/issues/16580; https://github.com/apache/lucene/pull/16418
  • https://github.com/apache/lucene/issues/8514 (LUCENE-7462); https://github.com/apache/lucene/issues/8941 (LUCENE-7890); https://lists.apache.org/thread/tm3tvnpfo130zbbdm7roj4m0gvncj6sx (java-user 'Read DocValue twice', 2018)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SegmentDocValuesProducer.java:218-224
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/OverlaySortedNumericDocValues.java:23-28, :41-54, :76-93
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocValuesOverlayMerger.java:22-30, :72-80
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2061-2072
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/comparators/LongComparator.java:79-107; search/SortedNumericSelector.java:135-142, :169-174, :194-201
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:93, :116, :122-126
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:2020-2037 (updateSortedNumericDocValue javadoc)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:110-115, :334-348 (default 8, 'Enabled by default', @lucene.experimental)
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:523-527 (GITHUB#16580: 'the field may be single- or multi-valued')
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ReadersAndUpdates.java:60, :363-368, :370-407, :600-613 (fold once prior delta generations >= maxDocValuesOverlays; a sparse fold keeps the base plus one folded generation; fold-to-dense once the prior deltas' docs-with-value reach 50% of maxDoc clears the overlay)
  • a Lucene clone 4ae946e5a9b (10.6.0) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:116 (DEFAULT_MAX_DOC_VALUES_OVERLAYS = 0), :343 ('Disabled by default')
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-33
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SortedNumericDocValues.java:28-38
  • https://github.com/apache/lucene/issues/8941 (LUCENE-7890); https://github.com/apache/lucene/issues/9165 (LUCENE-8117)
  • a Lucene main checkout (645d4fe14a20) lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndex.java:327-366
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:1176-1187
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:334-348
  • https://github.com/apache/lucene/pull/16581 (description)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestSortedNumericDocValuesUpdates.java (0 advanceExact calls; SortedNumericSortField only in testRejectIndexSortField, :328-331)
History
  • https://github.com/apache/lucene/pull/16581; https://github.com/apache/lucene/issues/16580; https://github.com/apache/lucene/pull/16418
  • a Lucene clone: git diff --stat 4ae946e5a9b 645d4fe14a20 -- OverlaySortedNumericDocValues.java DocValuesOverlayMerger.java (empty)

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 the code at 645d4fe14a20 and a traced run of the repro's three documents.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.