equals checks that the other object is its own class, then casts it to a different class: the sibling inside-distance sort it was copied from. That cast can never succeed, so every comparison of two outside sorts on one field throws.
Geo3DDocValuesField(modulespatial3d) buildsSortFields that order hits by distance from a shape:newDistanceSortandnewPathSort(distance from a point or path), and fivenewOutside*Sortfactories (distance outside a circle, box, polygon, large polygon or path; points inside get 0).- The first kind is a
Geo3DPointSortField, the outside kind aGeo3DPointOutsideSortField: two separate final, package-private subclasses ofSortField. - Like every
SortField, they are meant to compare by value:SortField.equalsandSort.equals"return true if o is equal to this", and code such as a cache keyed bySortrelies on that.
equals, and the ClassCastException. Select the still to open it at full size. Generated with AI, checked against the code.- Build
Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84)twice: two sorts by distance outside the same 1,000 m circle, with equal hash codes. - Compare them:
a.equals(b),new Sort(a).equals(new Sort(b)), or aHashMap<Sort, ?>lookup. You expecttrue, as two identical inside-distance sorts give (andfalsefor two different circles). - Instead every one of these calls throws
ClassCastException("Geo3DPointOutsideSortField cannot be cast to class Geo3DPointSortField") atGeo3DPointOutsideSortField.java:68, with assertions on and off. All fivenewOutside*Sortfactories do it. - Why:
equalsfirst checks that the argument is its own class (line 67), then casts it to the siblingGeo3DPointSortField(line 68), a line copied from that class in 2016. The two classes are unrelated, so the cast always fails. - Only comparing fails: sorted search and
searchAfternever reach the cast (with an index sort, search does call thisequals, against the index-sort field, and it returnsfalsefirst), Lucene itself never compares two such sorts, andequalsnever returns a wrongtrue. The fix is one line: cast toGeo3DPointOutsideSortField.
Applications that sort with an outside-distance sort and whose own code compares two separately built ones on the same field, for example a result cache keyed by Sort. Opt-in and rare: no upstream test builds one and no use was found in Elasticsearch, OpenSearch or Solr. No wrong results; workaround: reuse one SortField instance.
Mechanism, terms and numbers721 words
Geo3DDocValuesField has five public factories that sort hits by distance outside a shape, newOutside{Distance,Box,Polygon,LargePolygon,Path}Sort, each returning a package-private Geo3DPointOutsideSortField as a plain SortField. Its equals (Geo3DPointOutsideSortField.java:63-70 at 645d4fe14a20) checks getClass() != obj.getClass() (:67) and then casts the argument to the sibling inside-distance class Geo3DPointSortField (:68). The two are unrelated final subclasses of SortField, so the cast can never succeed, and because SortField.equals passes for any two outside sorts on the same field, comparing any two of them throws ClassCastException: directly, through Sort.equals, or in a HashMap keyed by Sort. The line was copied from Geo3DPointSortField.equals, where it names its own class. It dates from LUCENE-7212 (2016) and is in every release from 6.1.0 to 10.6.0 and on main. Searching and sorting never reach the cast, Lucene's own code never compares two such sorts, and equals never returns a wrong true; the fix is one line.
- Every outside sort on a field looks the same to
SortField.equals. The constructor callssuper(field, SortField.Type.CUSTOM, false, Double.POSITIVE_INFINITY)(Geo3DPointOutsideSortField.java:32).SortField.equals(SortField.java:451-461) compares only field, type, reverse, comparator source and missing value, andSortFieldhas no setter for them on main. Sosuper.equalsistruefor any two outside sorts on the same field. - The method then checks the class and casts to the wrong one (
Geo3DPointOutsideSortField.java:63-70):
public boolean equals(Object obj) {
if (this == obj) return true; // :65
if (!super.equals(obj)) return false; // :66
if (getClass() != obj.getClass()) return false; // :67 obj is a Geo3DPointOutsideSortField
final Geo3DPointSortField other = (Geo3DPointSortField) obj; // :68 unrelated final class: always throws
return distanceShape.equals(other.distanceShape); // :69 never reached
}Geo3DPointOutsideSortField (:26) and Geo3DPointSortField (Geo3DPointSortField.java:26) are both final class ... extends SortField, so no object is both. The jar's bytecode has checkcast org/apache/lucene/spatial3d/Geo3DPointSortField at line 68.
hashCodeis value-based (:53-61, over the shape), so two identical outside sorts land in the sameHashMapbucket and the lookup callsequals(a lookup with a different shape normally has a different hash code, so the map does not callequalsfor it).Sort.equalscompares the field arrays withArrays.equals(Sort.java:119), which calls eachSortField.equals.- What does not throw: the same instance (
:65;Arrays.equalsalso short-circuits on identical references), another field name ornull(:66), an inside sort (:67). - Lucene's own code never reaches the cast. Its non-sandbox
Sortcomparisons are the early-termination check of sorted search,TopFieldCollector.canEarlyTerminateOnPrefix(TopFieldCollector.java:160-172, comparison at:168), which on every sorted search over an index-sorted segment compares the search sort fields with the segment's index-sort fields and so does call thisequals;IndexWriter.isCongruentSort(IndexWriter.java:1232-1239) and the index-sort check inParallelLeafReader.java:136, which compare index sorts only; and grouping's comparisons withSort.RELEVANCE. An outside sort can never be an index sort (IndexWriterConfig.setIndexSortrejects a sort field with noIndexSorter,IndexWriterConfig.java:505-509, andSortField.getIndexSorterreturnsnullforCUSTOM,SortField.java:598-604), so in the early-termination checkequalsreturnsfalseat:66or:67, before the cast;Sort.RELEVANCE's field isnull, sosuper.equalsreturnsfalsefirst. An instrumented copy of the class showed it: a sorted search over an index-sorted index called thisequalsonce (fromcanEarlyTerminateOnPrefix) and without an index sort not at all. An executed probe ran sorted search (10 hits returned) andsearchAfter(does not throw) with outside sorts, alone and with a numeric sort, on a multi-segment index, with and without an index sort and an executor (3 of 3 pass onea57360912edand again on645d4fe14a20). (WhethersearchAfterpages correctly with these sorts is a separate bug, LFV-544.)
| comparison | expected | on main | with the fix |
|---|---|---|---|
two identical newOutsideDistanceSort | true | ClassCastException | true |
| two different shapes, same field, any of the five factories, both directions | false | ClassCastException | false |
Sort.equals over two identical outside sorts | true | ClassCastException | true |
HashMap<Sort, ?>.get with an equal Sort key | the entry | ClassCastException | the entry |
controls: a.equals(a); null, a foreign type, another field; inside vs outside | true; false; false | as expected | as expected |
| two identical inside-distance sorts (the sibling) | true | true | true |
Exposure and likelihood204 words
- Who is hit. Applications that sort with one of the five outside-distance sorts and whose own code compares two separately built ones on the same field: a result or request cache keyed by
Sort(or by query plus sort), code that de-duplicates or compares requests, or anassertEqualsin the application's tests. Every such comparison throws, deterministically; it does not depend on the data, an index or assertions. - Default vs opt-in. Opt-in and rare.
spatial3dis an optional module, and these sorts look like a niche corner of it, judging by the code inspected (real-world usage volume is unknown): no upstream test builds any Geo3D sort field, and read-only code searches of Elasticsearch, OpenSearch and Solr found no use ofGeo3DDocValuesFieldor of these factories (not exhaustive). Within that, it needs nothing special: the defaultPlanetModel.WGS84, ordinary arguments, no index. - Effect. A loud unchecked exception from
equals. No wrong results, no hang, no resource use, no index effect, and never a wrongtrue, so aSort-keyed cache cannot hand one request's results to another. - Workaround. Reuse one
SortFieldinstance per distinct sort (self-comparison returnstruebefore the cast), or key caches on the sort's parameters instead of theSortobject.
One JUnit test in lucene/spatial3d, public API only: build the same outside-distance sort twice and assertEquals them. No index, no randomness. It fails on main with assertions on and off, and on the 10.5.2 and 10.6.0 release jars.
/*
* 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 org.apache.lucene.search.SortField;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestGeo3DOutsideSortFieldEquals extends LuceneTestCase {
public void testIdenticalOutsideDistanceSortsAreEqual() {
SortField a =
Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84);
SortField b =
Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84);
// expected: a.equals(b) is true; actual: ClassCastException (cast to Geo3DPointSortField)
try {
assertEquals(a, b);
} catch (ClassCastException e) {
throw new AssertionError("expected a.equals(b) to return true, got " + e, e);
}
}
}
cp $REPRO/TestGeo3DOutsideSortFieldEquals.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DOutsideSortFieldEquals -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
- Expected
a.equals(b)returnstruefor two identicalnewOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84)sorts.- Observed
ClassCastException(Geo3DPointOutsideSortFieldcannot be cast toGeo3DPointSortField) atGeo3DPointOutsideSortField.java:68, with assertions on and off.
More files, environment, full output209 words
The try/catch only turns the exception into an AssertionError that states expected vs actual; the cause keeps the original stack. The test sits in org.apache.lucene.spatial3d for locality, as Lucene's own tests do, but uses no package-private member. Nothing random reaches the code path; the seed only satisfies the test framework.
Suggested regression test: this same test, as its own class or as one method in TestGeo3DDocValues.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.5.2 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run; pure Java logic) |
# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestGeo3DOutsideSortFieldEquals > testIdenticalOutsideDistanceSortsAreEqual FAILED
java.lang.AssertionError: expected a.equals(b) to return true, got java.lang.ClassCastException: class org.apache.lucene.spatial3d.Geo3DPointOutsideSortField cannot be cast to class org.apache.lucene.spatial3d.Geo3DPointSortField (org.apache.lucene.spatial3d.Geo3DPointOutsideSortField and org.apache.lucene.spatial3d.Geo3DPointSortField are in unnamed module of loader 'app')
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8165F61726058C4A]:0)
at org.apache.lucene.spatial3d.TestGeo3DOutsideSortFieldEquals.testIdenticalOutsideDistanceSortsAreEqual(TestGeo3DOutsideSortFieldEquals.java:34)
Caused by:
java.lang.ClassCastException: class org.apache.lucene.spatial3d.Geo3DPointOutsideSortField cannot be cast to class org.apache.lucene.spatial3d.Geo3DPointSortField (org.apache.lucene.spatial3d.Geo3DPointOutsideSortField and org.apache.lucene.spatial3d.Geo3DPointSortField are in unnamed module of loader 'app')
at org.apache.lucene.spatial3d.Geo3DPointOutsideSortField.equals(Geo3DPointOutsideSortField.java:68)
at junit@4.13.2/org.junit.Assert.isEquals(Assert.java:133)
at junit@4.13.2/org.junit.Assert.equalsRegardingNull(Assert.java:129)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:112)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at org.apache.lucene.spatial3d.TestGeo3DOutsideSortFieldEquals.testIdenticalOutsideDistanceSortsAreEqual(TestGeo3DOutsideSortFieldEquals.java:32)
:lucene:spatial3d:test (FAILURE): 1 test, 1 failure
# the same ClassCastException at Geo3DPointOutsideSortField.java:78 with the 10.6.0 (-ea, -da) and 10.5.2 (-ea) release jars
# with the one-line fix: 1 test, 0 failures (assertions on and off)- The class compares by value:
hashCodeandequalsboth use the shape. A cast that always fails after a same-class check is not a design. - The
SortFieldandSortjavadoc promise a boolean, andTestSort.testEqualsasserts that separately built equal sorts are equal. - Every sibling class casts to its own type; this line was copied from
Geo3DPointSortFieldin 2016.
The full argument318 words
- The class defines value equality. It overrides
hashCodeoverdistanceShape(Geo3DPointOutsideSortField.java:53-61), and itsequalsends by comparingdistanceShape(:69). A method that can never reach its own last line for two objects of its own class is not a design. - The contract promises a boolean.
SortField.equals: "Returns true ifois equal to this" (SortField.java:447);Sort.equals: the same (Sort.java:112), comparing the field arrays element by element (:119). TheObject#equalsgeneral contract describes an equivalence relation; for two instances of this classequalsgives no answer at all. Nothing (javadoc, comment,CHANGES.txt,MIGRATE.md, test) documents identity-only equality or an exception for these sorts; the factory javadoc covers only ordering, missing and multiple values and argument checks.Geo3DDocValuesFieldis public, in an exported package, with no@lucene.experimentalor@lucene.internaltag (thePlanetModelthat every factory takes as an argument is tagged@lucene.experimental,PlanetModel.java:26). - Every sibling uses the matching cast after the same guard:
Geo3DPointSortField.java:68,LatLonPointSortField.java:67,XYPointSortField.java:64. - Upstream tests treat sort equality as a contract:
TestSort.testEquals(TestSort.java:73-92) asserts that separately built equal sorts are equal and different ones unequal, in both directions;TestSortedNumericSortField.testEqualsandTestSortedSetSortField.testEqualsdo the same for single sort fields, in one direction. Sort/SortField value equality itself was added by LUCENE-1481 (#2555). - History shows a copy slip.
Geo3DPointSortFieldwas added on 2016-05-17 (07af00d8e7bc) with a correctequals; the next daycc263413457caddedGeo3DPointOutsideSortFieldand copied that method, cast included (git blame -C -Ctraces line 68 toGeo3DPointSortField.javaat07af00d8e7bc). The LUCENE-7212 discussion (#8267, 49 comments) never mentionsequals,hashCodeor a cast, and no later commit touched the line. - Precedent. #12827 (merged 2023-11-21, in 9.9.0) fixed the same kind of slip, an
equalsthat casts to a class it is not a subtype of, inPayloadFilteredTermIntervalsSource, with a one-line change.
The devil's-advocate check128 words
Strongest case (from the intended-behaviour checker and the cross-model reviews): the class is package-private, no Lucene code compares two of these sorts, no test builds one, the factories take an @lucene.experimental PlanetModel, no production user has been shown, and nobody has reported it since 2016, so the method is effectively unused and harmless.
Why it fails: that is about impact, not intent. Users get the object through public, documented, untagged factories (only their PlanetModel argument type is experimental) as a public SortField, and SortField.equals and Sort.equals are public, documented and tested; a cast that cannot succeed right after a same-class guard has no intended reading. The low impact is reflected in the priority (low), not in the verdict. The cross-model lenses rated the intended argument weak.
Recommended Cast to Geo3DPointOutsideSortField
Cast to the class's own type: final Geo3DPointOutsideSortField other = (Geo3DPointOutsideSortField) obj; (one line). With it the minimal test passes with assertions on and off, and the whole lucene/spatial3d suite runs clean (182 tests, 0 failures).
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!super.equals(obj)) return false;
if (getClass() != obj.getClass()) return false;
final Geo3DPointOutsideSortField other = (Geo3DPointOutsideSortField) obj;
return distanceShape.equals(other.distanceShape);
}All options and trade-offs2 options · 284 words
The recommended fix was applied with git apply to a scratch worktree of 645d4fe14a20 and run through gradle; the alternative was not run.
In Geo3DPointOutsideSortField.equals (:68), cast to the class's own type, as the sibling classes do.
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!super.equals(obj)) return false;
if (getClass() != obj.getClass()) return false;
final Geo3DPointOutsideSortField other = (Geo3DPointOutsideSortField) obj;
return distanceShape.equals(other.distanceShape);
}Trade-offs
No adverse trade-off was identified in the reviewed code and tests. For the tested specifications (all five factories, identical and different shapes), equals then returns true for identical specs and false for different shapes, matching its value-based hashCode and the sibling classes. No other code changes.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20with the repro test added, seed DEADBEEF: the repro test passes with assertions on and off;TestGeo3DDocValues(the closest existing suite) passes; the whole:lucene:spatial3d:test(20 classes, 182 tests including the repro) passes with assertions on: 0 failures, 3 skipped. The same suite without the fix: 1 failure, the repro. :lucene:spatial3d:checkGoogleJavaFormatpasses with the fix.- Standalone runner with the fixed class shadowed: a five-factory probe passes 5 of 5 (identical specs
true, different shapesfalse,Sort.equalstrue, theHashMaplookup finds the key), and an independent verifier's 9-test class passes 9 of 9.
Replace lines 67-68 with if (!(obj instanceof Geo3DPointOutsideSortField other)) return false;, the style SortField.equals itself uses (o instanceof SortField other, SortField.java:453). Not run.
Trade-offs
Equivalent here because the class is final; a two-line change instead of one, and it makes this exact slip impossible to repeat.
Downstream products are affected only if their own code compares or caches Sort objects that hold these outside-distance sorts; equals never returns a wrong true, so no cache can mix up results.
Static analysis: SpotBugs flagged that equals checks for an operand type incompatible with its own class, at line 68. A bytecode scan of all 599 equals(Object) methods in core and the non-sandbox modules found exactly this one cast to an unrelated type. A public-API JUnit then reproduced it.
The method in full113 words
Method: non-formal (static analysis, then a directed JUnit), 2026-10-26, main ea57360912ed. A sweep of the non-sandbox module jars with SpotBugs 4.10.5 (-effort:max -low) reported EQ_CHECK_FOR_OPERAND_NOT_COMPATIBLE_WITH_THIS (rank 14) at Geo3DPointOutsideSortField.java:68. A custom bytecode scan of all 599 equals(Object) methods in core and the non-sandbox modules, looking for a cast to a type the declaring class is not a subtype of, found exactly one hit, this one. The finder's JUnit (identical distance sorts, different box sorts, Sort.equals over polygon sorts, an inside-sort control) then failed 3 of 4 on main with -ea and -da and on 10.5.2 and 9.12.3. A later minimisation step replaced it with the one-method test shown here.
No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream projects or the web (searched 2026-10-26, re-run 2026-10-30). Closest: #12827, which fixed the same kind of slip in PayloadFilteredTermIntervalsSource in 2023.
Searches3 searches
Context, not reports: #12827 (the same slip in another class, fixed 2023); #8267 (LUCENE-7212, the introducing issue; nothing about equals); #14775 (open, the only open PR touching the file; adds inverseSort(), leaves equals unchanged); #9633 (LUCENE-8587, a different slip in GeoComplexPolygon.equals); #15480 and PRs #15483/#15486 (SortField immutability; touched the file only for setMissingValue); #2555 (LUCENE-1481, added Sort/SortField equality); #7802, #6572, #1903 (other equals issues in other classes). Tracking issue (#16937), dev@ thread and public bug-list page do not count; no maintainer or anyone else mentions this bug in them. Limits: keyword search only; GitHub search does not index review comments (the relevant threads were read directly); private channels are not searchable.
| Where | When | How | Found |
|---|---|---|---|
| GitHub, commits, downstream repositories, mailing lists, JIRA archive, web (search script) | 2026-10-26 | three runs, 141 queries over GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web | no report; the run with the class, factory and comparator names got 0 GitHub, commit, downstream or JIRA hits |
| GitHub (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET, code search) | 2026-10-26 | 67 manual searches; the changed files of all 411 open apache/lucene PRs; the review threads of the 6 PRs that touched the file; 24 mailing-list and 11 JQL queries; 12 web queries | no report; closest #12827 (precedent) |
| re-run: search script, GitHub, lists, web | 2026-10-30 | 239 queries and checks: 140 from two script runs, 99 manual, the changed files of all 408 open PRs, the review threads of #14775, #15483 and #15486 | no report; the cast is still on upstream main 2f424fd428da and branch_10_6 |
Born with the class: LUCENE-7212 (cc263413457c on main, aa339a2f0756 on branch_6x, 2016-05-18) copied equals from Geo3DPointSortField, cast included. First release 6.1.0; every release to 10.6.0 and main is affected; not fixed upstream.
Versions tested9 rows
cc263413457c ("LUCENE-7212: Add outside distance classes and methods.", Karl Wright, 2016-05-18) and its branch_6x twin aa339a2f0756 (same change and author date, the copy that shipped first, in 6.1.0) added Geo3DPointOutsideSortField with the wrong cast. They were committed directly under JIRA LUCENE-7212 (migrated as #8267, still open), so there is no PR. The line was copied from Geo3DPointSortField.equals, added the day before in 07af00d8e7bc, where the cast names its own class (git blame -C -C). On main, git log -S on the cast finds only cc263413457c (with --all, also the branch_6x twin and two repository-restructure commits), and git blame -C -C traces the line unchanged to its copy source; the file's 8 later commits did not change the line (the newest, f4f6d70c0c4c in #15486, removed the deprecated setMissingValue override). A javap check of all 91 lucene-spatial3d release jars from 6.1.0 to 10.6.0 finds the bad checkcast in every one. Upstream main 2f424fd428da and branch_10_6 (2026-10-30) still have it.
| Version or commit | Verdict | Result |
|---|---|---|
| 6.0.1 | API absent | no Geo3DDocValuesField (the probe does not compile) |
| 6.1.0 | first release | ClassCastException in equals with -ea and -da (main() probe) |
| 6.6.6, 7.0.0, 7.7.3, 8.4.1 | affected | ClassCastException in equals (main() probe, factories without the PlanetModel argument) |
| 8.5.0, 8.11.4, 9.0.0 | affected | ClassCastException in equals (main() probe with PlanetModel.WGS84) |
9.12.3, 10.0.0, 10.5.2, 10.6.0 (4ae946e5a9b9) | affected | the minimal test fails 1 of 1 (10.6.0 with -ea and -da) |
| all 91 release jars 6.1.0 to 10.6.0 | bytecode check | equals does checkcast Geo3DPointSortField in every jar |
main 645d4fe14a20 | affected | fails, gradle and standalone, assertions on and off |
upstream main 2f424fd428da, branch_10_6 | source read | the cast is still at line 68 / line 78 (2026-10-30) |
main 645d4fe14a20 + one-line fix | fix | the minimal test passes; the whole spatial3d suite: 182 tests, 0 failures, 3 skipped |
Independent verifiers, a reading-only cross-model review, an adjudication and a credibility audit confirmed the finder's report; the review's refute lens said not real only because it could not run Java. A re-verification on 645d4fe14a20 and this doc's runs reproduced it and validated the fix through gradle.
Checks and verdicts406 words
Every executed check reproduced the same ClassCastException at the cast, and every control passed.
| Check | Verdict | What it found |
|---|---|---|
| finder (static analysis, then a JUnit) | candidate | SpotBugs and a 599-method bytecode scan found the cast; the JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3, passes 4 of 4 with the fix; TestGeo3DDocValues passes with the fix. |
| Independent reproducer | real | Own 9-test JUnit in another package: 5 of 9 fail on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3, including HashMap<Sort,?>.get; the 4 controls pass; 9 of 9 with its own one-line fix. |
| cross-model review (OpenAI GPT-5.6, reading only) | intended lens: real, medium; refute lens: not real (procedural) | The refute lens could not run Java and calls its verdict procedural; its own findings agree with the root cause, put the inputs in contract and rate the intended argument weak. A separate re-run of the repro fails. |
| Intended-behaviour devil's advocate | real | Built the strongest not-a-bug case and showed it is about impact, not intent; re-ran the repro (-ea, -da: 3 of 4 fail). |
| adjudication | confirmed, high | Re-ran the independent reproduction's JUnit (-ea, -da: 5 of 9 fail, controls pass); not a duplicate. |
| credibility audit | keep | Re-ran the finder's JUnit with a fresh seed (-ea, -da: 3 of 4 fail); the auditor judged that a committer would merge the one-line fix without debate. |
| re-verification and tie-break on 645d4fe14a20 | real, high | The minimal test fails on main (-ea, -da), 10.6.0 (-ea, -da) and 10.5.2; a five-factory probe with controls fails its 3 bug tests and passes 5 of 5 with the fix; javap shows the bad checkcast. |
| this doc's runs (2026-10-30) | reproduced; fix validated | The minimal test fails through gradle on main 645d4fe14a20 with assertions on and off; with the fix it passes, and the whole spatial3d suite has 0 failures in 182 tests. |
| fact checks of this doc (2026-10-30) | bug confirmed; wording corrected | An independent fact check re-ran the repro through gradle (assertions on and off: 1 failure at line 68; with the fix 0, and the whole spatial3d suite 182 tests, 0 failures) and showed with an instrumented copy of the class that sorted search calls this equals only for the index-sort check, which returns false before the cast. A second, reading-only fact check by OpenAI GPT-5.6 found no error that changes a conclusion. Their wording corrections are applied. |
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 481 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,342 words
- Registry title
- Geo3DPointOutsideSortField.equals casts its argument to the sibling class Geo3DPointSortField, so comparing two distinct outside-distance sort fields on the same field (directly, through Sort.equals, or in a HashMap keyed by Sort) throws ClassCastException instead of returning true or false
- Area
- spatial3d / Geo3DDocValuesField outside-distance sort fields (Geo3DPointOutsideSortField equality)
- Priority
- Low, rank 684: Geo3DPointOutsideSortField.equals throws ClassCastException when two outside-distance sorts are compared (for example by a Sort-keyed cache), a loud failure in a barely used API, so it goes with its loud spatial3d sibling LFV-282 in the crash rows.
- Impact class
- crash
- Impact note
- Hits only applications that sort with one of the five Geo3DDocValuesField.newOutside{Distance,Box,Polygon,LargePolygon,Path}Sort factories of the opt-in spatial3d module (no upstream test builds one, and a read-only code search found no use in Elasticsearch, OpenSearch or Solr) and whose own code compares, or hashes and then compares, two separately built such sort fields on the same field, for example a result cache keyed by Sort: with the default WGS84 model and no setting or scale needed, that equals call throws ClassCastException with -ea and -da, while Lucene's own sorted search and searchAfter (executed) and its index-sort, grouping and query-cache paths (read) never reach the cast, so there are no wrong results, no hang and no index damage; every release from 6.1.0 to 10.6.0 (10.6.0 by reading) and main is affected.
- Summary
- You build the same outside-distance sort twice, for example Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84), and compare the two with equals, with Sort.equals, or by looking one up in a HashMap keyed by Sort, and expect true (or false for two different shapes), which is what the inside-distance sort gives. Instead equals throws ClassCastException ('Geo3DPointOutsideSortField cannot be cast to class Geo3DPointSortField'): the method was copied from the sibling class Geo3DPointSortField and still casts to that class right after checking that both objects are Geo3DPointOutsideSortField, so the cast can never succeed. Searching and sorting with these sort fields work; only comparing two of them fails.
- Introduced
- cc263413457c826914c3f3c1c26f885330a931aa (main, 'LUCENE-7212: Add outside distance classes and methods.', by Karl Wright (GitHub DaddyWri); first released in 7.0.0) and its branch_6x twin aa339a2f0756a487293c271a673e60e12722d661 (same change and author date, the commit that shipped first, in 6.1.0); committed under JIRA LUCENE-7212 (migrated as apache/lucene#8267, still open), no pull request. The wrong line was copied from Geo3DPointSortField.equals, added the day before in 07af00d8e7bc ('LUCENE-7212: Add Geo3D sorted document fields'), where the same cast is correct because it names its own class. [read: git blame -C -C on main attributes Geo3DPointOutsideSortField.java:68-69 to Geo3DPointSortField at 07af00d8e7bc; git log -L on the equals method and git log -S 'Geo3DPointOutsideSortField' list only cc263413457; exec: 6.0.1 has no such API, 6.1.0 fails]. Not a regression: the cast has never changed (2016-05-18)
- First affected version
- 6.1.0 [exec] (the first release with Geo3DDocValuesField and the outside-distance sort factories; fails with -ea and -da); 6.0.1 has no such API (the probe does not compile)
- Affected versions
- 6.1.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed; not fixed upstream. [exec] a public-API main() probe (two variants, before and after the PlanetModel argument that LUCENE-9150 added in 8.5.0) hits the ClassCastException in 5 of 5 checks (one per factory, one of them through Sort.equals) on 6.1.0 (-ea and -da), 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main, and passes 5 of 5 on main with the one-line fix; the finder's JUnit fails 3 of 4 (the control passes) on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 9 (the 4 controls pass) on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3. [read] all 81 release tags from 6.1.0 to 10.5.2 in the history clone contain the cast (8.8.2 and 8.9.0-8.11.3 are not in the clone and are inferred from 8.8.1 and the executed 8.11.4); upstream main e1c4184cb98, branch_10x, branch_10_6 and branch_9_12 still contain it (read-only check 2026-10-26). All runs on aarch64 with JDK 25.0.3
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal
- Upstream and prior reports
- none found (2026-10-26, read-only, the prior-report record verdict new, 262 logged queries and checks: three scripted prior-report searches (141 queries over GitHub issues/PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; the run with the class, factory and comparator names got 0 GitHub, commit, downstream or JIRA hits), 67 manual GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and apache/lucenenet plus code search, the changed files of all 411 open apache/lucene PRs, the review threads of the 6 PRs that touched the file, 24 direct mailing-list and 11 JQL queries, and 12 web queries; no hit is the same bug). Closest item: #12827 (merged 2023-11-21, released in 9.9.0) fixed the identical mistake, an equals that casts to a sibling class, in PayloadFilteredTermIntervalsSource; it does not mention Geo3D and has a different fix site, so it is a precedent, not a report of this bug. Also read, not this bug: #8267 (LUCENE-7212, still open, the introducing issue; its comments never mention equals, hashCode or a cast), apache/lucene-solr#1253 (LUCENE-9150, the last rewrite of the class), #14775 (open, the only open PR touching the file; adds inverseSort() and does not change equals), #7802 (LUCENE-6744), #6572 (LUCENE-5509, the LFV-039 item), #1903 (LUCENE-828). Not fixed upstream.
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-30: the minimal test through gradle in a scratch worktree with assertions on and off, stock and with the recommended fix (plusTestGeo3DDocValuesand the wholelucene/spatial3dtest suite with the fix and without it), and on the standalone runner with-eaand-daand fresh seeds; a five-factory probe with controls, stock and with the fix; the 10.5.2 and 10.6.0 release jars. The history runs cover 6.0.1 (API absent) and 6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (all affected), plus a bytecode check of all 91lucene-spatial3drelease jars from 6.1.0 to 10.6.0. The hunt itself ran on mainea57360912ed, identical in the files involved. Upstream main2f424fd428daandbranch_10_6were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.
- Finding record
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:53-61 (at ea57360912ed) (hashCode over distanceShape), :63-70 (equals; getClass check at :67, cast to Geo3DPointSortField at :68)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:63-70 (at ea57360912ed) (the sibling equals with the matching cast at :68)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:249, (at ea57360912ed) :281, :311, :337, :365 (public static factories newOutside{Distance,Box,Polygon,LargePolygon,Path}Sort return SortField)lucene/core/src/java/org/apache/lucene/search/SortField.java:446-461 (at ea57360912ed) (javadoc 'Returns true if o is equal to this'; equals), lucene/core/src/java/org/apache/lucene/search/Sort.java:113-120 (Sort.equals compares the SortField arrays element by element)
- The finding in 30 seconds
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:26, :32, :53-61, :63-70lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:26, :63-70 (the sibling, cast to its own class at :68)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:183, :216, :249, :281, :311, :337, :365 (factories); :257, :290, :314, :340, :373 (return new Geo3DPointOutsideSortField); :229-248 (newOutsideDistanceSort javadoc: points within the circle have distance 0.0)lucene/core/src/java/org/apache/lucene/search/SortField.java:446-461 (javadoc and equals; no setters for field, type, reverse or missing value on main)lucene/core/src/java/org/apache/lucene/search/Sort.java:112-120 (javadoc, Arrays.equals at :119)lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:59, :152, :160-172 (canEarlyTerminateOnPrefix, comparison at :168); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1232-1239 (isCongruentSort); lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504-509; lucene/core/src/java/org/apache/lucene/search/SortField.java:598-604 (getIndexSorter returns null for CUSTOM); lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:136; lucene/grouping/src/java/org/apache/lucene/search/grouping/AllGroupHeadsCollector.java:63, BlockGroupingCollector.java:275, :285, TopGroups.java:183, :405, AllGroupHeadsCollectorManager.java:146
- Who is affected
grep of lucene/spatial3d/src/test (no SortField, no newOutside* call); grep of lucene (no newOutside* call outside spatial3d/src/java)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:65 (self-comparison returns true before the cast)
- Why it looks unintended
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:53-61, :63-70lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:68; lucene/core/src/java/org/apache/lucene/document/LatLonPointSortField.java:67; lucene/core/src/java/org/apache/lucene/document/XYPointSortField.java:64lucene/core/src/java/org/apache/lucene/search/SortField.java:446-449; lucene/core/src/java/org/apache/lucene/search/Sort.java:112-120lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:40, :51, :229-248; lucene/spatial3d/src/java/module-info.java:22; lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/PlanetModel.java:26 (@lucene.experimental)lucene/core/src/test/org/apache/lucene/search/TestSort.java:61-92; TestSortedNumericSortField.java:60; TestSortedSetSortField.java:47
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/SortField.java:453 (instanceof pattern in SortField.equals)
- Diagrams
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:63-70
- TestGeo3DOutsideSortFieldEquals.java (lucene/spatial3d test, one method):
repro/LFV-352/src/org/apache/lucene/spatial3d/TestGeo3DOutsideSortFieldEquals.java(37 lines)
assets/LFV-352/bug.png(481 KB)
