All findings · Lucene finding · LFV-551 · geo3d-comparator-rereads-multivalued-docvalues

Geo3D distance sorts read a hit's points twice when a segment has a multi-valued document, so hits get another document's distance

Geo3D distance sorts re-read a hit's points from a forward-only cursor that has moved past them, and get the following documents' points. Top 2 of 5 documents: doc 0 at 1°, doc 2 at 5°, not doc 2 at 0.5°, doc 0 at 1°; or the search throws EOFException.

Summary

Priority
Medium, rank 191Geo3D distance sorts on multi-valued documents return wrong order and another document's distance on ordinary top-N queries, but the API is little used, so it opens the spatial3d sort rows after LFV-209.
Impact
silent wrong results
Since
7.0.0 (LUCENE-7407, 2016); 6.x correctintroduced 2016-09-21 · over 10 years
Affected
7.0.0 to 10.5.2 and main
Status
documented
Found by
code reading while writing a randomized paging harness, then a JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Geo3D doc-values sorts allow several points per document and promise the closest one. The comparator reads a document's points from a cursor that only moves forward, and Lucene's collector can ask it about the same hit two or three times.

Step-by-step diagram of the Geo3D sort re-read: five documents at latitudes 1, 2, (9 and 0.5), 5 and 6 degrees, top 2 by distance from (0, 0). compareBottom reads doc 2's two points and finds it competitive at 0.5 degrees; copy then reads two more values from the same forward-only cursor, doc 3's 5 degrees and doc 4's 6 degrees, so doc 2 is stored at 5 degrees and the result is doc 0, doc 2 instead of doc 2, doc 0. With a per-document value cache the second read reuses doc 2's own points.
The example's five documents and the two reads of doc 2: compareBottom reads its two points, copy reads two more, which belong to docs 3 and 4, so doc 2 is stored at 5° and drops behind doc 0. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 5 documents with Geo3DDocValuesField at latitudes {1}, {2}, {9, 0.5}, {5}, {6} degrees along one meridian: doc 2 has two points. Default codec, one segment.
  2. Ask for the top 2 by Geo3DDocValuesField.newDistanceSort from (0, 0). You expect doc 2 at 0.5° (its closest point, as the javadoc promises), then doc 0 at 1°.
  3. Instead you get doc 0 at 1°, then doc 2 at 5°: doc 2 carries doc 3's point. With the two-point document last, or with searchAfter, the search throws EOFException.
  4. Why: the collector can ask the comparator about the same document two or three times: compareBottom (once the queue is full) and/or compareTop (with searchAfter), then copy. Each call reads the points with nextValue(), and nothing rewinds in between, so the later calls read the following documents' points.
  5. One two-point document is enough: single-valued hits in that segment go wrong too, whenever a hit gets two or more of those calls. Segments where every document has one point, and searches that ask for every hit without searchAfter, are correct.

Who is affected

Applications that store several points per document in Geo3DDocValuesField (opt-in spatial3d module) and sort with any of its seven sort factories, with a normal top-N request or searchAfter paging. Default codec and configuration; 3 documents are enough. The API is little used: no upstream test sorts with it.

Mechanism, terms and numbers761 words

Geo3DDocValuesField allows several points per document and promises that its sorts use the closest one (Geo3DDocValuesField.java:34-35, :173-174). TopFieldCollector can ask the comparator about the same hit two or three times: compareBottom (once the queue is full) and/or compareTop (with searchAfter), then copy. Geo3DPointDistanceComparator and Geo3DPointOutsideDistanceComparator read the document's values with SortedNumericDocValues.nextValue() in each call and never rewind, although nextValue() must not be called more than docValueCount() times per document. The default codec does not check, so a later call reads the next docValueCount() values, which belong to the following documents; copy stores that distance as the hit's sort value, and the order follows it; past the last value the search throws (RuntimeException(EOFException) on file directories on 9.12.3 and later, IndexOutOfBoundsException on 9.0.0 and on a plain heap directory; 7.x and 8.x return a wrong value). In the repro, top 2 of {1}, {2}, {9, 0.5}, {5}, {6} degrees returns doc 0 at 1° and doc 2 at 5° (doc 3's point) instead of doc 2 at 0.5° and doc 0 at 1°. It needs a segment with at least one multi-valued document and a hit that gets two or more of those calls, which a top-N smaller than the hit count or searchAfter makes possible. LUCENE-7407 (f7aa200d406, 7.0.0) introduced it when it moved doc values to iterators and, in the same commit, gave the 2D twin LatLonPointDistanceComparator a per-document value cache; the Geo3D comparators never got one. Adding the same cache fixes it.

Terms

  • Geo3D doc-values sort: Geo3DDocValuesField stores each point as one encoded long in a SORTED_NUMERIC field; "Multiple values for the same field in one document is allowed" (Geo3DDocValuesField.java:34-35). Seven factories build a SortField (newDistanceSort, newPathSort, newOutsideDistanceSort, newOutsideBoxSort, newOutsidePolygonSort, newOutsideLargePolygonSort, newOutsidePathSort), each promising "If a document contains multiple values for the field, the closest distance ... is used", with that distance returned in FieldDoc (:167-174 and siblings). They use two comparators, Geo3DPointDistanceComparator and Geo3DPointOutsideDistanceComparator.
  • The cursor: SortedNumericDocValues.nextValue() "Iterates to the next value in the current document. Do not call this more than docValueCount times for the document" (SortedNumericDocValues.java:28-32). In the default codec it is values.get(start++) with no bound (Lucene90DocValuesProducer.java:2069-2072). If every document of a segment has at most one value, the codec returns a singleton wrapper whose nextValue() returns the same value every time (:2002-2004).
  • The repeated question: once the queue is full, TopFieldCollector calls compareBottom and then copy for the same competitive document (TopFieldCollector.java:92-93, 114-118, 203-213); with searchAfter it calls compareTop and then, for a hit after the cursor, copy, with compareBottom first once the queue is full, so an accepted hit gets three calls (:268-291). Only before the queue fills and without searchAfter is a hit just copied, with one call.

The bug

  1. compareBottom advances to the document if the cursor is behind and reads its docValueCount() values (Geo3DPointDistanceComparator.java:97-134, read at :110).
  2. copy calls computeMinimumDistance(doc) (:137-139). It advances only when doc > currentDocs.docID(), which is false for the same document, and reads docValueCount() more values (:163-182, read at :171). compareTop goes through the same method (:159-161).
  3. Those extra reads return the next docValueCount() values in storage order, which belong to the following document or documents that have the field; past the last value they throw: on main and 10.5.2, RuntimeException(EOFException) on file directories and on the test framework's heap directory (MockDirectoryWrapper over ByteBuffersDirectory), IndexOutOfBoundsException on a plain ByteBuffersDirectory.
  4. The slot stores the minimum of those values; value(slot) returns it as the hit's FieldDoc distance (:152-156), and the queue is ordered by it. Geo3DPointOutsideDistanceComparator has the same shape (:75-104, :107-109, :129-131, :133-152).

The numbers

Latitudes {1}, {2}, {9, 0.5}, {5}, {6} degrees at longitude 0, one segment, top 2 by distance from (0, 0). Docs 0 and 1 fill the queue (one read each), so the bottom is doc 1 at 2°. Doc 2: compareBottom reads 9° and 0.5°, and 0.5° beats 2°. copy reads two more values, doc 3's 5° and doc 4's 6°, and stores 5°. Docs 3 and 4 do not beat 5°. Result: doc 0 at 1.00°, doc 2 at 5.00°, instead of doc 2 at 0.50°, doc 0 at 1.00°. The same on the unit sphere and on WGS84 (111,194 m and 555,976 m), with assertions on and off. Lucene's AssertingDirectoryReader fails inside copy with valueUpto=2 in.docValueCount()=2.

Exposure and likelihood373 words
  • Who is exposed. Users of the Geo3D doc-values sorts whose documents can hold several points, for example a store with several branches or a route with several stops, as the javadoc allows. A read-only code search found no use of this API in Elasticsearch, OpenSearch or Solr.
  • What it takes. A segment with at least one document that has two or more points (then single-valued hits in that segment are re-read too), and either numHits below the number of hits or searchAfter. Those are necessary, not sufficient: a wrong read needs a hit that gets two or more value-reading calls, so a top-N search in which no hit after the first numHits is competitive stays correct (read from the code, not executed). Default codec, IndexWriterConfig, directory and searcher; deterministic; assertions on or off make no difference.
  • What you see. While the extra reads stay inside the field's values: wrong order and a wrong FieldDoc distance, with no error. Only when they pass the end of the field's values does the request fail, on a healthy index: IndexOutOfBoundsException on 9.0.0; on 9.12.3 and later RuntimeException(EOFException: read past EOF ...) on file directories and IndexOutOfBoundsException on a plain heap directory (the versions in between were not run); 7.x and 8.x return a wrong value instead.
  • Which value is wrong depends on storage order. The second read takes the next docValueCount() values in storage order, from the following document or documents, whether or not they match: in an executed probe a permitted hit reported the exact distance of a document hidden by a FILTER clause, by a live-docs reader wrapper or by a delete (0.2530 instead of 0.01745 on the unit sphere), and three searches with different sort origins recovered the hidden document's location to within 2.55e-05 degrees. This needs an application that returns FieldDoc sort values; only the distance and outside-distance sorts, filters, hard deletes and a live-docs wrapper were executed. The hit set itself is not widened.
  • How likely. Plausible: in a segment with a multi-valued document, a top-N request re-reads whenever a hit after the first numHits is competitive, and a searchAfter page re-reads every hit it accepts, deterministically; but the API is little used and no public report was found.

Minimal repro

The test shows that a top-2 Geo3DDocValuesField.newDistanceSort search over a segment with a two-point document returns the wrong order and another document's distance, because the comparator reads the document's doc values twice.

TestGeo3DDistanceSortMultiValued.java (JUnit test)68 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.spatial3d;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.FieldDoc;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.spatial3d.geom.GeoPoint;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestGeo3DDistanceSortMultiValued extends LuceneTestCase {

  public void testTopTwoIsHeadOfFullSort() throws IOException {
    PlanetModel planet = PlanetModel.WGS84;
    Directory dir = new ByteBuffersDirectory();
    IndexWriter w =
        new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()));
    for (double[] latitudes : new double[][] {{1}, {2}, {9, 0.5}, {5}, {6}}) {
      Document doc = new Document();
      for (double lat : latitudes) {
        doc.add(new Geo3DDocValuesField("p", new GeoPoint(planet, Math.toRadians(lat), 0), planet));
      }
      w.addDocument(doc);
    }
    w.close();
    DirectoryReader reader = DirectoryReader.open(dir);
    IndexSearcher searcher = new IndexSearcher(reader);
    Sort sort = new Sort(Geo3DDocValuesField.newDistanceSort("p", 0, 0, 2_000_000, planet));

    TopDocs all = searcher.search(MatchAllDocsQuery.INSTANCE, 5, sort);
    TopDocs top2 = searcher.search(MatchAllDocsQuery.INSTANCE, 2, sort);
    // expected: doc 2, doc 0 as in the full sort; actual: doc 0, then doc 2 at doc 3's distance
    assertEquals(firstTwo(all), firstTwo(top2));
    reader.close();
    dir.close();
  }

  private static String firstTwo(TopDocs topDocs) {
    FieldDoc a = (FieldDoc) topDocs.scoreDocs[0];
    FieldDoc b = (FieldDoc) topDocs.scoreDocs[1];
    return "doc " + a.doc + " at " + a.fields[0] + " m, doc " + b.doc + " at " + b.fields[0] + " m";
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-551/, 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:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$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.spatial3d.TestGeo3DDistanceSortMultiValued
Expected vs actual
Expected the head of the full sort, doc 2 at 55,599 m then doc 0 at 111,194 m; actual: doc 0, then doc 2 at 555,976 m (doc 3's distance), with -ea and -da.

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

More files, environment, full output50 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
    => org.junit.ComparisonFailure: expected:<doc [2 at 55599.27446323976 m, doc 0 at 111193.82953747343] m> but was:<doc [0 at 111193.82953747343 m, doc 2 at 555975.6320987428] m>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.spatial3d.TestGeo3DDistanceSortMultiValued.testTopTwoIsHeadOfFullSort(TestGeo3DDistanceSortMultiValued.java:58)

Why it looks unintended

  • The javadoc allows several points per document and promises the closest one (Geo3DDocValuesField.java:34-35, :173-174).
  • nextValue(): "Do not call this more than docValueCount times for the document"; Lucene's own AssertingLeafReader fails inside copy.
  • LUCENE-7407 gave the 2D twin LatLonPointDistanceComparator a per-document value cache in the commit that broke the Geo3D comparators; 6.x was correct.
The full argument294 words
  • The javadoc promises it. "Multiple values for the same field in one document is allowed" (Geo3DDocValuesField.java:34-35); every factory says the closest value is used and that FieldDoc holds the distance (:167-174 and siblings). The returned distance belongs to another document. Neither the field nor the comparators are @lucene.experimental; PlanetModel, a parameter of every sort factory, is (PlanetModel.java:23-27), which bears on API stability and priority, not on whether a wrong documented result is intended.
  • The read rule is explicit (SortedNumericDocValues.java:28-31), and the test framework enforces it: AssertingDirectoryReader fails with valueUpto=2 in.docValueCount()=2 at computeMinimumDistance:171 <- copy:138 <- TopFieldCollector...collectCompetitiveHit:116 (executed).
  • The double question is Lucene's own and documented. LeafFieldComparator: compareBottom "Compare a new hit (docID) against the 'weakest' (bottom) entry", copy "Installs a new hit into the priority queue ... when a new hit is competitive" (LeafFieldComparator.java:32-37); TopFieldCollector does exactly that.
  • The twins handle the same pattern. LatLonPointDistanceComparator (:68-69, :120-133, :192) and XYPointDistanceComparator (:63-64, :106-119, :176) read each document's values once into currentValues and reset per leaf.
  • History shows an oversight. 6.x read with setDocument(doc) + valueAt(i), which can be repeated; 6.1.0-6.6.6 are correct (executed). LUCENE-7407 (f7aa200d406) replaced it with nextValue() in both Geo3D comparators and added the cache to the LatLon twin only; adding just that cache to 7.0.0 and 7.7.3 makes them correct (executed).
  • Known pattern. When the iterator API was designed, LUCENE-7462 (#8514) made advanceExact accept the current document because a strict version "caused issues with comparators that may need to get the value multiple times".
  • No statement of intent. No javadoc, CHANGES, MIGRATE, test or issue limits Geo3D sorts to single-valued fields; upstream has no Geo3D sort test at all.
The devil's-advocate check203 words

The intended-behaviour checker built the strongest case: (a) FieldComparator and LeafFieldComparator are Expert and @lucene.experimental (so is PlanetModel, a parameter of every Geo3D sort factory), and nothing says in so many words that copy follows compareBottom for the same document, so a comparator could read once per call; (b) the feature looks unfinished: LUCENE-7212 (#8267) is still open with "tests" listed as missing, no upstream test sorts with Geo3D, the class javadoc still has a "TBD" list, and during design a committer asked how necessary multi-valued support really is; (c) nextValue() puts the burden on the caller, and the codec is deliberately unchecked, so a wrong value or an EOFException is the allowed outcome of breaking the rule.

Why it fails: the caller that breaks the rule is Lucene's own comparator, driven by Lucene's own collector in the order the LeafFieldComparator javadoc describes, and the two core twins cache values for exactly that order; missing tests do not make wrong results intended, multi-valued support is documented on a public field, and 6.x implemented it correctly; the codec skips the check because callers must respect docValueCount. Verdict: a defect (the adjudication: confirmed, high). Expect "low-traffic module" pushback on priority, not on validity.

Suggested fix

Recommended Cache the current document's values once per document (validated)

Read each document's points once into a small array and reuse them in compareBottom, compareTop and copy, exactly as LatLonPointDistanceComparator does (+54/-4 in the two Geo3D comparators). The new test passes 7 of 7, and the whole spatial3d module (188 tests) passes with assertions on and off.

private long[] currentValues = new long[4];
private int valuesDocID = -1;

private void setValues() throws IOException {
  if (valuesDocID != currentDocs.docID()) {
    valuesDocID = currentDocs.docID();
    int count = currentDocs.docValueCount();
    if (count > currentValues.length) {
      currentValues = new long[ArrayUtil.oversize(count, Long.BYTES)];
    }
    for (int i = 0; i < count; i++) currentValues[i] = currentDocs.nextValue();
  }
}
// compareBottom, computeMinimumDistance: setValues(); read currentValues[i]; getLeafComparator: valuesDocID = -1;
All options and trade-offs3 options · 491 words

One fix was built and validated: the per-document value cache of the 2D twins. Two alternatives were not built.

Cache the current document's values once per document (validated) (recommended)

In Geo3DPointDistanceComparator and Geo3DPointOutsideDistanceComparator: add currentValues/valuesDocID and a setValues() that reads the current document's values once; call it in compareBottom and computeMinimumDistance and read from the array; reset valuesDocID = -1 in getLeafComparator. A copy of LatLonPointDistanceComparator.java:68-69, :120-133, :192, including its ordering assert (left out of the snippet). 2 files, +54/-4.

private long[] currentValues = new long[4];
private int valuesDocID = -1;

private void setValues() throws IOException {
  if (valuesDocID != currentDocs.docID()) {
    valuesDocID = currentDocs.docID();
    int count = currentDocs.docValueCount();
    if (count > currentValues.length) {
      currentValues = new long[ArrayUtil.oversize(count, Long.BYTES)];
    }
    for (int i = 0; i < count; i++) currentValues[i] = currentDocs.nextValue();
  }
}
// compareBottom, computeMinimumDistance: setValues(); read currentValues[i]; getLeafComparator: valuesDocID = -1;

Trade-offs

One long[] per comparator (4 longs at first; it grows to the largest value count seen and is kept for the comparator's life, across segments) and a doc-id check per call. Doc values are read once per document instead of two or three times, but compareBottom, compareTop and copy still each decode the points and compute distances. Same design as the core twins, so easy to review (in 2016 the LatLon comparator lived in lucene/sandbox). A helper shared by both comparators, or caching each document's minimum distance (keeping compareBottom's bounding-box shortcut), are alternatives. No benchmark was run.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test fails 6 of 7 with assertions on and off; + fix the new test passes 7 of 7 with assertions on and off.
  • The whole lucene/spatial3d module: 20 suites, 188 tests, 3 skipped, 0 failures, with assertions on and off; :lucene:spatial3d:check -x test (ecjLint, forbiddenApis, checkGoogleJavaFormat) passes.
  • Standalone runner with the fix: the independent reproducer's 10 tests (including the AssertingDirectoryReader probe), the original 5 tests and the hidden-document probe's 7 tests all pass.
  • The fix's code comment was reworded after the fact checks (code unchanged); every run above was repeated with the reworded fix.
  • Earlier: the same cache added to 7.0.0 and 7.7.3 fixed the history probe there.

Re-position with advanceExact(doc) before the second read (not built)

In computeMinimumDistance, used by copy and compareTop, call currentDocs.advanceExact(doc) instead of advancing only when the cursor is behind; DocValuesIterator allows the current document as the target (DocValuesIterator.java:25-28).

Trade-offs

Smaller diff, but it relies on advanceExact of the same document resetting the value cursor: the Lucene90 iterators and AssertingLeafReader do, no javadoc says so. It decodes and computes distances two or three times per competitive hit.

Document Geo3D sorts as single-valued only (rejected)

Say in the javadoc that only one point per document is supported.

Trade-offs

Contradicts the field's own javadoc, 6.x behaviour and the 2D twins, and leaves the exception path in place.

Downstream note

Because the second read lands on the documents stored next, a hit can carry the distance of a document that a filter, a live-docs wrapper or a delete hides from the search; in one probe three searches recovered a hidden document's location to within 2.55e-05 degrees. It matters only to applications that return sort values and hide documents that way.

How it was found

Code reading: while writing the reference comparator of a randomized harness that compares paged and unpaged sorted searches, the finder noticed that compareBottom and copy drain the same doc-values cursor. The harness then showed it on random tiny indexes, and a minimal JUnit confirmed it on the stock jars.

The method in full149 words

Method: code reading, witnessed by a randomized metamorphic harness and a JUnit (the hunt's non-formal tier).

  • The hunt unit checked metamorphic relations of SortField paging across modules: the top N must be the head of the full order, and searchAfter pages must concatenate to it.
  • While writing the harness's reference for the Geo3D comparators, the finder saw that compareBottom and copy both loop over nextValue() for the same document.
  • On the stock main jar the harness's Geo3D family gave 2,811 paging mismatches and 2,267 IndexOutOfBoundsExceptions in 3,000 random trials, counted together with a separate Geo3D searchAfter units bug (LFV-544); 0 with both fixes, while with only LFV-544's fix 1,070 mismatches and 853 exceptions remained.
  • The finder reduced it to a 5-test public-API JUnit. A review by another model family (OpenAI GPT-5.6, read-only) widened the trigger to single-valued hits in a multi-valued segment, which a new executed test confirmed.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26): about 205 queries (280 with two earlier re-read runs) over GitHub issues, PRs and commits of Lucene, Solr, Elasticsearch and OpenSearch, the JIRA archive, the mailing lists and the web, plus the changed files of all 407 open Lucene PRs. The nearest items are the feature issue #8267 and LUCENE-7407, which introduced it.

Searches6 searches

No issue, PR, JIRA ticket, list thread, downstream report or web page describes the Geo3D comparators reading doc values twice, wrong Geo3D sort order or values on multi-valued points, or EOFException from Geo3D sorts. Neither comparator has changed on upstream main (ffa9d816eeb, 2026-10-26) or branch_10x since 2021, and no open PR touches them.

Closest, not this bug: #8267 (LUCENE-7212, the open feature issue; all comments predate the bug), LUCENE-7407 / #8460 (the introducing change; its comments never mention spatial3d), #8514 (LUCENE-7462: comparators "may need to get the value multiple times"), a 2018 java-user@ thread "Read DocValue twice" (the same exception class in user code), open PR #14775 (inverse sort on the Geo3D sort fields; leaves the comparators alone) and #16847 (other spatial3d files).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, commits, downstream repositories, mailing lists, JIRA archive, web2026-10-26two scripted prior-report search runs: 51 queries on the Geo3D sort classes, factories and symptoms, 24 on the generic nextValue/docValueCount/comparator contract; the finder's 51-query run and the devil's advocate's runonly #8267, LUCENE-7407 and unrelated items
GitHub (apache/lucene, apache/lucene-solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr)2026-10-2653 manual searches, open and closed, by class, factory, symptom and JIRA key (read-only GraphQL)no report
Open PRs2026-10-26the changed files of all 407 open apache/lucene PRsnone touches either comparator or Geo3DDocValuesField.java; #14775 and #16847 touch other Geo3D files
JIRA archive2026-10-2618 JQL searches over LUCENE and SOLR; LUCENE-7407, 7460, 7462, 8482, 8834, 9150 and 9263 readno report
Mailing lists2026-10-2649 searches over dev@, java-user@, issues@, commits@ lucene and the Solr listsone 2018 java-user@ thread about re-reading doc values in user code
Web2026-10-2610 queriesno report

History

Introduced in 7.0.0 by LUCENE-7407 (f7aa200d406, 2016-09-21), which switched doc values to iterators. Executed: 6.1.0, 6.2.1 and 6.6.6 are correct; every executed release from 7.0.0 to 10.5.2 and main is wrong, and from 9.0.0 the end-of-data case throws instead of returning a wrong value.

Versions tested8 rows

Introduced by LUCENE-7407 "switch doc values usage to an iterator API" (@mikemccand, f7aa200d406, 2016-09-21; a JIRA patch committed directly, migrated issue #8460). It converted both Geo3D comparators from setDocument(doc) + valueAt(i) to docValueCount() + nextValue() without a cache and, in the same commit, added the currentValues/valuesDocID cache to LatLonPointDistanceComparator. The read loops are unchanged since (only reformatting and the LUCENE-9150 decode change touched those methods). The sort API itself came with LUCENE-7212 (#8267, still open; May 2016) and first shipped, correct, in 6.1.0. 65 release tags contain the commit, plus 8.8.2-8.11.4 cut from the old combined repository. Not fixed upstream; branch_10_6 (10.6.0, released 2026-10-29) and the unreleased branch_10x (both read 2026-10-24) have the same comparators.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 6 of 7 fail (gradle and the standalone runner, assertions on and off, 5 seeds)
10.5.2 affectedthe brief's test 6 of 7 fail (assertions on and off); the independent reproducer 10 of 10
9.12.3, 10.0.0 affectedhistory probe: wrong top 2; last-document case RuntimeException(EOFException) on file directories, IndexOutOfBoundsException on a plain ByteBuffersDirectory
9.0.0 affectedhistory probe: wrong top 2; last-document case IndexOutOfBoundsException on both the file (MMap) and the plain heap directory
7.0.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4 affectedhistory probe: wrong top 2; last-document case a wrong distance (about 9° instead of 0.5°), no exception
6.1.0, 6.2.1, 6.6.6 not affectedhistory probe correct (random-access doc values)
7.0.0 and 7.7.3 + only the value cache commit isolationcorrect; the unmodified 7.0.0 sources shadowed in still fail
main + fix not affectedthe brief's test 7 of 7; the whole lucene/spatial3d module, 188 tests, 0 failed

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and a review by another model family (OpenAI GPT-5.6) call it real; the adjudication re-ran the reproducer's tests (10 of 10 fail, all pass with the fix) and confirmed it. A registry re-run and an audit reproduced it on main and 10.5.2.

Checks and verdicts457 words

The independent reproducer, the devil's advocate, the adjudication and the registry re-run ran tests on the stock jars with assertions on and off, on main ea57360912ed (the cited files are identical on a9779ef3c193). The cross-model reviews were read-only and ran no tests of their own: only a separate run re-ran a repro (the original JUnit, 5 of 5 fail), and the review was given the original repro's results file. Three checks of this doc followed: an independent fact check that re-ran the test, a read-only fact check by another model family and a diagram check; their corrections are in this version.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateRandomized paging harness on the stock jar, then a 5-test public-API JUnit that fails 5 of 5 on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix.
cross-model review real (high)OpenAI GPT-5.6, read-only: root cause and fix agree, inputs in contract; its correction (single-valued hits in a multi-valued segment are hit too) was adopted and verified.
Independent reproducer realOwn 10-test JUnit, oracle computed without Lucene, fails 10 of 10 on main (-ea, -da), 10.5.2 and 9.12.3; controls pass; the AssertingDirectoryReader probe fires in copy; its own cache fix passes.
Cross-model review real (high); refute lens: no verdictOpenAI GPT-5.6, intended lens: the "expert, experimental comparator API" defence is weak because Lucene's own collector makes the calls and the twins cache. The refute lens returned no verdict.
Intended-behaviour devil's advocate real (high)Production-like main() (file directory, forceMerge(1)) fails identically on main and 10.5.2, including a searchAfter EOFException on all 7 factories; no won't-fix or by-design statement found.
adjudication confirmed (high)Re-ran the independent reproduction's JUnit (10 of 10 fail, -ea and -da), its controls (pass) and the independent reproduction's JUnit with the finder's fix (10 of 10 pass). Duplicate of none.
registry re-run and audit keepThe finder's JUnit fails 5 of 5 with fresh seeds (-ea, -da, 10.5.2) and passes with the fix; an independent main() probe with a file directory and the default configuration fails on main and 10.5.2 and passes with the fix.
this doc's runs reproduced, fix validatedThe new 7-test JUnit on a9779ef3c193 and 10.5.2, and the fix through gradle with the whole spatial3d module.
fact checks of this doc bug confirmed; wording correctedAn independent fact check re-ran the test (main, assertions on and off, 10.5.2, the fix: 6 of 7 fail, 7 of 7 with the fix); a read-only fact check by another model family (OpenAI GPT-5.6) and a diagram check. None changed the bug, its root cause or its classification.

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

Finding record

LFV-551, snapshot of 2026-11-06

Registry title
Geo3D distance sorts read a document's doc values twice in any segment where some document has two or more points, so top-N and searchAfter results come back in the wrong order with another document's distance as the sort value (even a filtered-out or deleted document's), or the search throws EOFException (every release since 7.0.0)
Area
spatial3d / Geo3DDocValuesField sort comparators (Geo3DPointDistanceComparator, Geo3DPointOutsideDistanceComparator; all seven Geo3D sort factories)
Priority
Medium, rank 191: Geo3D distance sorts on multi-valued documents return wrong order and another document's distance on ordinary top-N queries, but the API is little used, so it opens the spatial3d sort rows after LFV-209.
Impact class
silent wrong results
Impact note
Applications that store two or more points per document in Geo3DDocValuesField (explicitly allowed by its javadoc; spatial3d module, a little-used API: no upstream test sorts with it and a read-only code search found no use in Elasticsearch, OpenSearch or Solr) and sort with any of the seven Geo3D sort factories get, whenever the top-N queue fills (numHits below the hit count, the normal case) or when they page with searchAfter, a wrong order and FieldDoc distances that belong to the next document with values, also for single-valued hits in such a segment, or a RuntimeException(EOFException) / IndexOutOfBoundsException when the extra reads pass the end of the field's values (9.0.0 and later; 7.x and 8.x return a wrong distance there instead), with the default codec, IndexWriterConfig, directory and searcher, -ea and -da, on indexes of three to five documents, in every release from 7.0.0 to 10.5.2 and on main; segments in which every document has at most one value, and searches with numHits >= hits and no searchAfter, are correct.
Summary
You index documents with Geo3DDocValuesField, at least one of them with two or more points (the javadoc allows this and says the closest one is used), and ask for the top 2 hits sorted by Geo3DDocValuesField.newDistanceSort; you expect the head of the full sorted order, each hit carrying its own closest distance. Instead the top 2 come back in the wrong order and a hit reports the distance of the next document, because the comparator reads the document's values once in compareBottom (or compareTop) and again in copy without re-positioning the SortedNumericDocValues iterator, which its javadoc forbids ('Do not call this more than docValueCount times'), so the second read takes the following document's values or runs past the end of the data and throws EOFException. The same happens with searchAfter paging and all seven Geo3D sort factories, and because document order decides which value is read, a hit can report the distance of a document that a filter, a live-docs wrapper or a delete hides from the search.
Introduced
f7aa200d406dbd05a35d6116198302d90b92cb29 (LUCENE-7407 'Explore switching doc values to an iterator API', migrated GitHub issue #8460, committed by mikemccand from a patch attached to the JIRA issue; no pull request) [read: git show f7aa200d406, git log -S 'currentDocs.nextValue()' and git log -L of compareBottom and computeMinimumDistance in the history clone; commit isolation executed: 7.0.0 and 7.7.3 pass with only a per-document value cache added to both Geo3D comparators, and still fail with the unmodified 7.0.0 sources shadowed in]. It converted Geo3DPointDistanceComparator and Geo3DPointOutsideDistanceComparator from setDocument(doc)/valueAt(i) (idempotent random access) to advance(doc)/docValueCount()/nextValue() (a forward-only cursor) with no per-document cache, while the same commit added the valuesDocID/currentValues cache to LatLonPointDistanceComparator. The sort API itself came earlier with LUCENE-7212 (07af00d8e7b and cc263413457 on master, 8a407f0399c and aa339a2f075 on branch_6x, 2016-05-17/18) and was correct then. (2016-09-21 (f7aa200d406, LUCENE-7407); the sort API dates from 2016-05-17/18 (LUCENE-7212))
First affected version
7.0.0 [exec] (the first release containing f7aa200d406); not affected: 6.1.0, 6.2.1 and 6.6.6 [exec] (random-access doc values API; the Geo3D sort API first shipped in 6.1.0), and no 6.x release contains the commit [read]
Affected versions
every release from 7.0.0 to 10.5.2 plus main ea57360912ed; never fixed. [exec] the follow-up's history probe (main(), public API, WGS84, newDistanceSort and newOutsideDistanceSort, FS and heap directories) fails on 7.0.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and passes on 6.1.0, 6.2.1 and 6.6.6; the finder's JUnit fails 5 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 10 of 10 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's probe fails on main and 10.5.2; the security probe fails 7 of 7 on main (-ea, -da), 10.5.2 and 9.12.3. The last-document layout returns a silently wrong distance on 7.x and 8.x and throws (IndexOutOfBoundsException or RuntimeException(EOFException)) from 9.0.0, a change attributed to the Lucene90 doc-values codec by observation, not bisected. [read] 65 release tags contain f7aa200d406, plus 8.8.2-8.11.4 cut from the old combined repository's branch_8x (8.11.4 executed); the read loops are unchanged since f7aa200d406; upstream main (ffa9d816eeb, 2026-10-26) and branch_10x have not touched either comparator since 2021. Not executed: the releases between the executed points and the 10.6.x/10.7.x branches
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, the intended-behaviour check's run, read-only; the prior-report record, verdict new): two follow-up scripted prior-report searches (51 queries on the Geo3D sort classes, factories and symptoms over GitHub, commits, downstream repos, the mailing lists, the JIRA archive and the web; 24 queries on the generic nextValue/docValueCount/comparator contract over the lists and JIRA), the finder's 51-query run, the intended-behaviour check's 24-query run, 53 manual GitHub searches (apache/lucene open and closed, apache/lucene-solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr), 18 JIRA JQL searches (LUCENE and SOLR), 49 mailing-list searches, 10 web searches, the changed files of all 407 open apache/lucene PRs and the upstream history of the five Geo3D sort files. Related, not this bug: #8267 (LUCENE-7212, open feature issue under which the comparators were added; all comments predate the bug); LUCENE-7407 / #8460 (introducing change; its comments never mention spatial3d); #8514 (LUCENE-7462: advanceExact accepts target == current doc because 'comparators may need to get the value multiple times'; no mention of Geo3D or multi-valued re-reads); java-user@ 2018-02 'Read DocValue twice' (the same exception class in user code, answered with 're-position with advanceExact'); #8512 (LUCENE-7460), LUCENE-8834, #9528 (LUCENE-8482), LUCENE-9150, LUCENE-9263, #12082, #7757 (API discussions or unrelated fixes); open PR #14775 (inverseSort on the Geo3D sort fields, does not touch the comparators) and #16847 (touches other spatial3d files). No open PR changes either comparator. Not fixed upstream.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off and 5 seeds; the 10.5.2 release jars (assertions on and off). The earlier checks ran on main ea57360912ed, whose cited files are identical, and on 9.12.3; a history probe ran on 6.1.0, 6.2.1, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main. The fix was validated in the scratch worktree: the new test with assertions on and off, the whole lucene/spatial3d module (188 tests) with assertions on and off, and :lucene:spatial3d:check -x test.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:97-134 (at ea57360912ed) (compareBottom: advance only when doc > docID, then nextValue() docValueCount() times at :110), :137-139 (copy -> computeMinimumDistance), :142-150 (getLeafComparator, no cache reset), :153-156 (value(slot): the sort value returned in FieldDoc), :159-161 (compareTop -> computeMinimumDistance), :163-182 (computeMinimumDistance: no re-positioning when doc == docID, nextValue() docValueCount() more times at :171)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:75-104 (at ea57360912ed) (compareBottom, nextValue at :88), :107-109 (copy), :129-131 (compareTop), :133-152 (computeMinimumDistance, nextValue at :141)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:34-35 (at ea57360912ed) ('Multiple values for the same field in one document is allowed'), :167-168 (the FieldDoc value is the distance in meters), :173-174 ('If a document contains multiple values for the field, the closest distance from the circle center is used', and the same sentence for the other factories), :183, 216, 249, 281, 311, 337, 365 (the seven sort factories)
  • lucene/core/src/java/org/apache/lucene/index/SortedNumericDocValues.java:28-32 (at ea57360912ed) (nextValue: 'Do not call this more than docValueCount times for the document')
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2070-2072 (at ea57360912ed) (multi-valued nextValue = values.get(start++), no bound)
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:92-93 (at ea57360912ed) (thresholdCheck -> compareBottom), :114-118 (collectCompetitiveHit -> copy), :203-212 (SimpleFieldCollector: compareBottom then copy for the same doc), :270-290 (PagingFieldCollector: compareTop then copy)
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:68-69, (at ea57360912ed) :120-133, :192 (the core twin's per-document value cache and its reset per leaf); lucene/core/src/java/org/apache/lucene/document/XYPointDistanceComparator.java:63-64 (same cache)
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:34-35, (at a9779ef3c193) :167-174 and the six sibling factory javadocs (:197-205, :233-240, :264-271, :297-304, :323-330, :347-354)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:97-134, (at a9779ef3c193) :137-139, :152-156, :159-161, :163-182; lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:75-104, :107-109, :129-131, :133-152
  • lucene/core/src/java/org/apache/lucene/index/SortedNumericDocValues.java:28-32; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2002-2004, :2069-2072
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:92-93, (at a9779ef3c193) :114-118, :203-213, :268-291
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:203-213, (at a9779ef3c193) :268-291 (which hits get two or more calls)
Why it looks unintended
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:34-35, (at a9779ef3c193) :37-40 (TBD list), :167-174
  • lucene/core/src/java/org/apache/lucene/index/SortedNumericDocValues.java:28-31; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:32-37, :41
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:68-69, (at a9779ef3c193) :120-133, :192; lucene/core/src/java/org/apache/lucene/document/XYPointDistanceComparator.java:63-64, :106-119, :176
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/PlanetModel.java:23-27 (at a9779ef3c193) (@lucene.experimental)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:68-69, (at a9779ef3c193) :120-133, :192
  • lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:25-28; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2061-2066; lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:1177-1185
History
  • a Lucene clone: git show origin/branch_10_6 and origin/branch_10x of both comparators and Geo3DDocValuesField.java, compared with a9779ef3c193 (identical)

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

Repro files

About the diagrams

A step-by-step picture of the repro's main example, made in parallel with this doc from an executed probe on main a9779ef3c193 and the code; the PNG is the final summary card. The alt text describes the intended content.

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.