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.
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.- Index 5 documents with
Geo3DDocValuesFieldat latitudes {1}, {2}, {9, 0.5}, {5}, {6} degrees along one meridian: doc 2 has two points. Default codec, one segment. - Ask for the top 2 by
Geo3DDocValuesField.newDistanceSortfrom (0, 0). You expect doc 2 at 0.5° (its closest point, as the javadoc promises), then doc 0 at 1°. - 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 throwsEOFException. - Why: the collector can ask the comparator about the same document two or three times:
compareBottom(once the queue is full) and/orcompareTop(withsearchAfter), thencopy. Each call reads the points withnextValue(), and nothing rewinds in between, so the later calls read the following documents' points. - 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.
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.
- Geo3D doc-values sort:
Geo3DDocValuesFieldstores each point as one encodedlongin aSORTED_NUMERICfield; "Multiple values for the same field in one document is allowed" (Geo3DDocValuesField.java:34-35). Seven factories build aSortField(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 inFieldDoc(:167-174and siblings). They use two comparators,Geo3DPointDistanceComparatorandGeo3DPointOutsideDistanceComparator. - The cursor:
SortedNumericDocValues.nextValue()"Iterates to the next value in the current document. Do not call this more thandocValueCounttimes for the document" (SortedNumericDocValues.java:28-32). In the default codec it isvalues.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 whosenextValue()returns the same value every time (:2002-2004). - The repeated question: once the queue is full,
TopFieldCollectorcallscompareBottomand thencopyfor the same competitive document (TopFieldCollector.java:92-93, 114-118, 203-213); withsearchAfterit callscompareTopand then, for a hit after the cursor,copy, withcompareBottomfirst once the queue is full, so an accepted hit gets three calls (:268-291). Only before the queue fills and withoutsearchAfteris a hit just copied, with one call.
compareBottomadvances to the document if the cursor is behind and reads itsdocValueCount()values (Geo3DPointDistanceComparator.java:97-134, read at:110).copycallscomputeMinimumDistance(doc)(:137-139). It advances only whendoc > currentDocs.docID(), which is false for the same document, and readsdocValueCount()more values (:163-182, read at:171).compareTopgoes through the same method (:159-161).- 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 (MockDirectoryWrapperoverByteBuffersDirectory),IndexOutOfBoundsExceptionon a plainByteBuffersDirectory. - The slot stores the minimum of those values;
value(slot)returns it as the hit'sFieldDocdistance (:152-156), and the queue is ordered by it.Geo3DPointOutsideDistanceComparatorhas the same shape (:75-104,:107-109,:129-131,:133-152).
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
numHitsbelow the number of hits orsearchAfter. 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 firstnumHitsis 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
FieldDocdistance, with no error. Only when they pass the end of the field's values does the request fail, on a healthy index:IndexOutOfBoundsExceptionon 9.0.0; on 9.12.3 and laterRuntimeException(EOFException: read past EOF ...)on file directories andIndexOutOfBoundsExceptionon 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 aFILTERclause, 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 returnsFieldDocsort 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
numHitsis competitive, and asearchAfterpage re-reads every hit it accepts, deterministically; but the API is little used and no public report was found.
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.
/*
* 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";
}
}
# 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
-eaand-da.
More files, environment, full output50 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> 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)- 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 ownAssertingLeafReaderfails insidecopy.- LUCENE-7407 gave the 2D twin
LatLonPointDistanceComparatora 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 thatFieldDocholds the distance (:167-174and 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:AssertingDirectoryReaderfails withvalueUpto=2 in.docValueCount()=2atcomputeMinimumDistance: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);TopFieldCollectordoes exactly that. - The twins handle the same pattern.
LatLonPointDistanceComparator(:68-69,:120-133,:192) andXYPointDistanceComparator(:63-64,:106-119,:176) read each document's values once intocurrentValuesand 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 withnextValue()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
advanceExactaccept 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.
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.
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/spatial3dmodule: 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
AssertingDirectoryReaderprobe), 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.
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.
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.
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.
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
SortFieldpaging across modules: the top N must be the head of the full order, andsearchAfterpages must concatenate to it. - While writing the harness's reference for the Geo3D comparators, the finder saw that
compareBottomandcopyboth loop overnextValue()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 Geo3DsearchAfterunits 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.
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub, commits, downstream repositories, mailing lists, JIRA archive, web | 2026-10-26 | two 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 run | only #8267, LUCENE-7407 and unrelated items |
| GitHub (apache/lucene, apache/lucene-solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr) | 2026-10-26 | 53 manual searches, open and closed, by class, factory, symptom and JIRA key (read-only GraphQL) | no report |
| Open PRs | 2026-10-26 | the changed files of all 407 open apache/lucene PRs | none touches either comparator or Geo3DDocValuesField.java; #14775 and #16847 touch other Geo3D files |
| JIRA archive | 2026-10-26 | 18 JQL searches over LUCENE and SOLR; LUCENE-7407, 7460, 7462, 8482, 8834, 9150 and 9263 read | no report |
| Mailing lists | 2026-10-26 | 49 searches over dev@, java-user@, issues@, commits@ lucene and the Solr lists | one 2018 java-user@ thread about re-reading doc values in user code |
| Web | 2026-10-26 | 10 queries | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the brief's test 6 of 7 fail (gradle and the standalone runner, assertions on and off, 5 seeds) |
| 10.5.2 | affected | the brief's test 6 of 7 fail (assertions on and off); the independent reproducer 10 of 10 |
| 9.12.3, 10.0.0 | affected | history probe: wrong top 2; last-document case RuntimeException(EOFException) on file directories, IndexOutOfBoundsException on a plain ByteBuffersDirectory |
| 9.0.0 | affected | history 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 | affected | history 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 affected | history probe correct (random-access doc values) |
| 7.0.0 and 7.7.3 + only the value cache | commit isolation | correct; the unmodified 7.0.0 sources shadowed in still fail |
| main + fix | not affected | the brief's test 7 of 7; the whole lucene/spatial3d module, 188 tests, 0 failed |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Randomized 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 | real | Own 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 verdict | OpenAI 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 | keep | The 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 validated | The 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 corrected | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 429 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,611 words
- 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 mainea57360912ed, 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 wholelucene/spatial3dmodule (188 tests) with assertions on and off, and:lucene:spatial3d:check -x test.
- 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-152lucene/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-2072lucene/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-174lucene/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, :41lucene/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, :176lucene/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, :192lucene/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)
- TestGeo3DDistanceSortMultiValued.java (JUnit test):
repro/LFV-551/src/org/apache/lucene/spatial3d/TestGeo3DDistanceSortMultiValued.java(68 lines)
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.
assets/LFV-551/bug.png(429 KB)
